Skip to content

Instantly share code, notes, and snippets.

@simonw

simonw/demo.html Secret

Created March 16, 2026 20:09
Show Gist options
  • Select an option

  • Save simonw/985ae2a6a3cd3df3fd375eb58dabea0f to your computer and use it in GitHub Desktop.

Select an option

Save simonw/985ae2a6a3cd3df3fd375eb58dabea0f to your computer and use it in GitHub Desktop.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Trees SQL Map Demo</title>
<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""
/>
<style>
:root {
color-scheme: light dark;
--bg: #f7f7fb;
--fg: #1f2937;
--card: #ffffff;
--muted: #6b7280;
--error: #b91c1c;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #111827;
--fg: #f9fafb;
--card: #1f2937;
--muted: #9ca3af;
--error: #fca5a5;
}
}
body {
margin: 0;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--fg);
}
main {
max-width: 980px;
margin: 2rem auto;
padding: 0 1rem 2rem;
}
h1 {
margin-bottom: 0.25rem;
font-size: 1.5rem;
}
p {
margin-top: 0;
color: var(--muted);
}
.panel {
background: var(--card);
border-radius: 12px;
padding: 1rem;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
}
.controls {
display: flex;
gap: 0.5rem;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
input[type="text"] {
flex: 1 1 22rem;
border: 1px solid #9ca3af;
border-radius: 8px;
padding: 0.5rem 0.625rem;
font: inherit;
color: inherit;
background: transparent;
}
button {
border: 1px solid #6b7280;
border-radius: 8px;
padding: 0.5rem 0.75rem;
font: inherit;
cursor: pointer;
background: transparent;
color: inherit;
}
textarea {
width: 100%;
min-height: 10rem;
border: 1px solid #9ca3af;
border-radius: 8px;
padding: 0.625rem;
font: 0.9rem/1.4 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
color: inherit;
background: transparent;
box-sizing: border-box;
margin-bottom: 0.75rem;
}
#status {
margin: 0 0 0.75rem;
font-weight: 600;
}
#status.error {
color: var(--error);
}
#map {
height: 520px;
border: 1px solid #d1d5db;
border-radius: 10px;
margin-bottom: 0.75rem;
overflow: hidden;
}
pre {
margin: 0.75rem 0 0;
overflow: auto;
font-size: 0.9rem;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-word;
max-height: 14rem;
}
</style>
</head>
<body>
<main>
<h1>Trees SQL Map</h1>
<p>Run a query and render all returned points as a heat map. The default query targets roughly 200,000 trees.</p>
<section class="panel" aria-live="polite">
<div class="controls">
<input id="pathInput" type="text" value="/trees/-/query.json" aria-label="Query JSON endpoint" />
<button id="loadButton" type="button">Run Query</button>
</div>
<textarea id="sqlInput" aria-label="SQL query">SELECT
cast(Latitude AS float) AS latitude,
cast(Longitude AS float) AS longitude,
CASE
WHEN DBH IS NULL OR DBH = '' THEN 0.3
WHEN cast(DBH AS float) <= 0 THEN 0.3
WHEN cast(DBH AS float) >= 80 THEN 1.0
ELSE round(0.3 + (cast(DBH AS float) / 80.0) * 0.7, 3)
END AS weight
FROM trees
WHERE Latitude IS NOT NULL
AND Latitude != ''
AND Longitude IS NOT NULL
AND Longitude != ''
LIMIT 200000</textarea>
<p id="status">Loading...</p>
<div id="map" role="img" aria-label="Map of query results"></div>
<pre id="output"></pre>
</section>
</main>
<script
src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""
></script>
<script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
<script>
const map = L.map('map').setView([37.7749, -122.4194], 12);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; OpenStreetMap contributors'
}).addTo(map);
let heatLayer = null;
function buildQueryUrl(path, sql) {
const url = new URL(path, window.location.origin);
url.searchParams.set('sql', sql);
url.searchParams.set('_size', 'max');
return `${url.pathname}${url.search}`;
}
function asNumber(value) {
const n = Number(value);
return Number.isFinite(n) ? n : null;
}
function rowsToHeatPoints(rows) {
return rows.map((row) => {
const lat = asNumber(row.latitude ?? row.Latitude);
const lon = asNumber(row.longitude ?? row.Longitude);
const weightRaw = asNumber(row.weight ?? row.dbh ?? row.DBH);
if (lat == null || lon == null) {
return null;
}
const weight = weightRaw == null ? 0.4 : Math.max(0.05, Math.min(1, weightRaw));
return [lat, lon, weight];
}).filter(Boolean);
}
function renderHeatMap(points) {
if (!points.length) {
throw new Error('No mappable latitude/longitude fields found in query rows.');
}
if (heatLayer) {
map.removeLayer(heatLayer);
}
heatLayer = L.heatLayer(points, {
radius: 16,
blur: 22,
maxZoom: 17,
minOpacity: 0.25,
gradient: {
0.2: '#93c5fd',
0.4: '#5eead4',
0.6: '#86efac',
0.8: '#4ade80',
1.0: '#166534'
}
}).addTo(map);
const latlngs = points.map((point) => [point[0], point[1]]);
const bounds = L.latLngBounds(latlngs);
map.fitBounds(bounds.pad(0.08));
return latlngs.length;
}
async function fetchAllRows(requestPath, statusEl) {
let nextPath = requestPath;
const allRows = [];
while (nextPath) {
statusEl.textContent = `Fetching rows... ${allRows.length.toLocaleString()} loaded`;
const response = await fetch(nextPath, {
headers: { Accept: 'application/json' }
});
if (!response.ok) {
throw new Error(`Request failed with ${response.status} ${response.statusText}`);
}
const data = await response.json();
if (!data || !Array.isArray(data.rows)) {
throw new Error('Response does not include a rows array.');
}
allRows.push(...data.rows);
nextPath = data.next_url || null;
}
return allRows;
}
async function runQuery(path, sql) {
const statusEl = document.getElementById('status');
const outputEl = document.getElementById('output');
try {
statusEl.classList.remove('error');
const requestPath = buildQueryUrl(path, sql);
statusEl.textContent = `Running query via ${requestPath}...`;
outputEl.textContent = '';
const rows = await fetchAllRows(requestPath, statusEl);
const points = rowsToHeatPoints(rows);
const plotted = renderHeatMap(points);
statusEl.textContent = `Loaded ${rows.length.toLocaleString()} rows and plotted ${plotted.toLocaleString()} points as heat map.`;
outputEl.textContent = JSON.stringify(rows.slice(0, 200), null, 2);
} catch (error) {
statusEl.textContent = 'Failed to run query.';
statusEl.classList.add('error');
outputEl.textContent = error instanceof Error ? error.message : String(error);
}
}
const pathInput = document.getElementById('pathInput');
const sqlInput = document.getElementById('sqlInput');
const loadButton = document.getElementById('loadButton');
function runFromInput() {
const path = pathInput.value.trim() || '/trees/-/query.json';
const sql = sqlInput.value.trim();
runQuery(path, sql);
}
loadButton.addEventListener('click', runFromInput);
pathInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
runFromInput();
}
});
runFromInput();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment