-
-
Save simonw/985ae2a6a3cd3df3fd375eb58dabea0f to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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: '© 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