From ba96d0a197a5bd0dfe1bdcd652b3e94df615fd69 Mon Sep 17 00:00:00 2001 From: Dan Snow Date: Fri, 1 Nov 2024 17:31:38 -0400 Subject: [PATCH] Add six-color auto-adjusting viridis scale --- site/assets/css/main.scss | 34 +++---- site/assets/js/map.js | 205 ++++++++++++++++++++++++-------------- 2 files changed, 144 insertions(+), 95 deletions(-) diff --git a/site/assets/css/main.scss b/site/assets/css/main.scss index 393ec6f..0af6a19 100644 --- a/site/assets/css/main.scss +++ b/site/assets/css/main.scss @@ -11,18 +11,12 @@ --link-color: #2979bc; --visited-link-color: #8f5baa; --full-width: 720px; - - // Map color scale vars (viridis) - --color-less-15-min: rgba(253, 231, 37, 0.5); - --color-15-30-min: rgba(180, 222, 44, 0.5); - --color-30-45-min: rgba(109, 205, 89, 0.5); - --color-45-60-min: rgba(53, 183, 121, 0.5); - --color-60-90-min: rgba(31, 158, 137, 0.5); - --color-90-120-min: rgba(38, 130, 142, 0.5); - --color-2-3-hrs: rgba(49, 104, 142, 0.5); - --color-3-4-hrs: rgba(62, 74, 137, 0.5); - --color-4-6-hrs: rgba(72, 40, 120, 0.5); - --color-more-6-hrs: rgba(68, 1, 84, 0.5); + --map-color-1: rgba(253, 231, 37, 0.5); + --map-color-2: rgba(122, 209, 81, 0.5); + --map-color-3: rgba(34, 168, 132, 0.5); + --map-color-4: rgba(42, 120, 142, 0.5); + --map-color-5: rgba(65, 68, 135, 0.5); + --map-color-6: rgba(68, 1, 84, 0.5); } html { @@ -370,8 +364,17 @@ blockquote p, padding: 10px; border-radius: 5px; box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1); + min-width: 90px; +} + +#map-color-scale.collapsed { + padding: 0; min-width: 30px; min-height: 30px; + + >div:not(:last-child) { + display: none; + } } #map-color-scale div { @@ -411,13 +414,6 @@ blockquote p, line-height: 1; } -#map-color-scale.collapsed { - padding: 0; - - >div:not(:last-child) { - display: none; - } -} @keyframes spinner { 0% { diff --git a/site/assets/js/map.js b/site/assets/js/map.js index b95e058..70b1a69 100644 --- a/site/assets/js/map.js +++ b/site/assets/js/map.js @@ -1,8 +1,72 @@ import * as duckdb from "https://cdn.jsdelivr.net/npm/@duckdb/duckdb-wasm@1.29.0/+esm"; +const zoomThresholds = [6, 8]; const protocol = new pmtiles.Protocol(); maplibregl.addProtocol("pmtiles", protocol.tile); +class ColorScale { + constructor(map) { + this.map = map; + this.scaleContainer = document.createElement("div"); + this.toggleButton = document.createElement("button"); + this.colors = [ + { color: "var(--map-color-1)", label: "< 15 min" }, + { color: "var(--map-color-2)", label: "15-30 min" }, + { color: "var(--map-color-3)", label: "30-45 min" }, + { color: "var(--map-color-4)", label: "45-60 min" }, + { color: "var(--map-color-5)", label: "60-75 min" }, + { color: "var(--map-color-6)", label: "75-90 min" }, + ]; + this.init(); + } + + init() { + this.scaleContainer.id = "map-color-scale"; + this.toggleButton.id = "map-color-scale-toggle"; + + this.toggleButton.innerHTML = "−"; // Unicode for minus sign + this.toggleButton.onclick = () => { + const isCollapsed = this.scaleContainer.classList.toggle("collapsed"); + this.toggleButton.innerHTML = isCollapsed ? "+" : "−"; // Unicode for plus and minus signs + }; + + const legendTitle = document.createElement("div"); + legendTitle.innerHTML = "

Travel time
(driving)

"; + this.scaleContainer.append(legendTitle); + + this.colors.forEach(({ color, label }) => { + const item = document.createElement("div"); + const colorBox = document.createElement("div"); + const text = document.createElement("span"); + text.textContent = label; + colorBox.style.backgroundColor = color; + item.append(colorBox, text); + this.scaleContainer.append(item); + }); + + this.scaleContainer.append(this.toggleButton); + this.map.getContainer().append(this.scaleContainer); + } + + updateLabels(zoom) { + const labels = this.getLabelsForZoom(zoom); + const items = this.scaleContainer.querySelectorAll("div > span"); + items.forEach((item, index) => { + item.textContent = labels[index]; + }); + } + + getLabelsForZoom(zoom) { + if (zoom < zoomThresholds[0]) { + return ["< 1 hr", "1-2 hrs", "2-3 hrs", "3-4 hrs", "4-5 hrs", "5-6 hrs"]; + } else if (zoom < zoomThresholds[1]) { + return ["< 30 min", "30-60 min", "1.0-1.5 hrs", "1.5-2.0 hrs", "2.5-3.0 hrs", "3.0-3.5 hrs"]; + } else { + return ["< 15 min", "15-30 min", "30-45 min", "45-60 min", "60-75 min", "75-90 min"]; + } + } +} + class Spinner { constructor() { this.spinner = document.createElement("div"); @@ -79,15 +143,11 @@ function addMapLayers(map) { "fill-color": [ "case", ["==", ["feature-state", "tract_color"], "color_1"], "rgba(253, 231, 37, 0.5)", - ["==", ["feature-state", "tract_color"], "color_2"], "rgba(180, 222, 44, 0.5)", - ["==", ["feature-state", "tract_color"], "color_3"], "rgba(109, 205, 89, 0.5)", - ["==", ["feature-state", "tract_color"], "color_4"], "rgba(53, 183, 121, 0.5)", - ["==", ["feature-state", "tract_color"], "color_5"], "rgba(31, 158, 137, 0.5)", - ["==", ["feature-state", "tract_color"], "color_6"], "rgba(38, 130, 142, 0.5)", - ["==", ["feature-state", "tract_color"], "color_7"], "rgba(49, 104, 142, 0.5)", - ["==", ["feature-state", "tract_color"], "color_8"], "rgba(62, 74, 137, 0.5)", - ["==", ["feature-state", "tract_color"], "color_9"], "rgba(72, 40, 120, 0.5)", - ["==", ["feature-state", "tract_color"], "color_10"], "rgba(68, 1, 84, 0.5)", + ["==", ["feature-state", "tract_color"], "color_2"], "rgba(122, 209, 81, 0.5)", + ["==", ["feature-state", "tract_color"], "color_3"], "rgba(34, 168, 132, 0.5)", + ["==", ["feature-state", "tract_color"], "color_4"], "rgba(42, 120, 142, 0.5)", + ["==", ["feature-state", "tract_color"], "color_5"], "rgba(65, 68, 135, 0.5)", + ["==", ["feature-state", "tract_color"], "color_6"], "rgba(68, 1, 84, 0.5)", "rgba(255, 255, 255, 0.0)" ], }, @@ -117,6 +177,24 @@ function addMapLayers(map) { }); } +function updateMapFill(map, previousStates) { + previousStates.forEach(state => + map.setFeatureState( + { source: "protomap", sourceLayer: "tracts", id: state.id }, + { tract_color: getColorScale(state.duration, map.getZoom()) } + ) + ); +} + +function wipeMapPreviousState(map, previousStates) { + previousStates.forEach(state => + map.setFeatureState( + { source: "protomap", sourceLayer: "tracts", id: state.id }, + { tract_color: "none" } + ) + ); +} + // Create display for current tract function createTractIdDisplay() { const display = document.createElement("div"); @@ -126,64 +204,28 @@ function createTractIdDisplay() { return display; } -function addColorScale(map) { - const scaleContainer = document.createElement("div"); - const toggleButton = document.createElement("button"); - scaleContainer.id = "map-color-scale"; - toggleButton.id = "map-color-scale-toggle"; - - toggleButton.innerHTML = "−"; // Unicode for minus sign - toggleButton.onclick = () => { - const isCollapsed = scaleContainer.classList.toggle("collapsed"); - toggleButton.innerHTML = isCollapsed ? "+" : "−"; // Unicode for plus and minus signs - }; - - const legendTitle = document.createElement("div"); - legendTitle.innerHTML = "

Travel time
(driving)

"; - scaleContainer.append(legendTitle); - - const colors = [ - { color: "var(--color-less-15-min)", label: "< 15 min" }, - { color: "var(--color-15-30-min)", label: "15-30 min" }, - { color: "var(--color-30-45-min)", label: "30-45 min" }, - { color: "var(--color-45-60-min)", label: "45-60 min" }, - { color: "var(--color-60-90-min)", label: "60-90 min" }, - { color: "var(--color-90-120-min)", label: "90-120 min" }, - { color: "var(--color-2-3-hrs)", label: "2-3 hrs" }, - { color: "var(--color-3-4-hrs)", label: "3-4 hrs" }, - { color: "var(--color-4-6-hrs)", label: "4-6 hrs" }, - { color: "var(--color-more-6-hrs)", label: "> 6 hrs" }, - ]; - - colors.forEach(({ color, label }) => { - const item = document.createElement("div"); - const colorBox = document.createElement("div"); - const text = document.createElement("span"); - text.textContent = label; - colorBox.style.backgroundColor = color; - item.append(colorBox, text); - scaleContainer.append(item); - }); - - scaleContainer.append(toggleButton); - map.getContainer().append(scaleContainer); -} - -// Color scale based on duration -const colorScale = (duration) => { - if (duration < 900) return "color_1"; - if (duration < 1800) return "color_2"; - if (duration < 2700) return "color_3"; - if (duration < 3600) return "color_4"; - if (duration < 5400) return "color_5"; - if (duration < 7200) return "color_6"; - if (duration < 10800) return "color_7"; - if (duration < 14400) return "color_8"; - if (duration < 21600) return "color_9"; - if (duration < 28800) return "color_10"; +// Color scale based on duration and zoom +const getColorScale = (duration, zoom) => { + const thresholds = getThresholdsForZoom(zoom); + if (duration < thresholds[0]) return "color_1"; + if (duration < thresholds[1]) return "color_2"; + if (duration < thresholds[2]) return "color_3"; + if (duration < thresholds[3]) return "color_4"; + if (duration < thresholds[4]) return "color_5"; + if (duration < thresholds[5]) return "color_6"; return "none"; }; +function getThresholdsForZoom(zoom) { + if (zoom < zoomThresholds[0]) { + return [3600, 7200, 10800, 14400, 21600, 28800]; + } else if (zoom < zoomThresholds[1]) { + return [1800, 3600, 5400, 7200, 10800, 14400]; + } else { + return [900, 1800, 2700, 3600, 5400, 7200]; + } +} + (async () => { const spinner = new Spinner(); spinner.show(); @@ -197,9 +239,9 @@ const colorScale = (duration) => { })() ]); + const colorScale = new ColorScale(map); const db = await DuckDB.connect(); db.query("LOAD parquet"); - addColorScale(map); spinner.hide(); let hoveredPolygonId = null; @@ -264,22 +306,33 @@ const colorScale = (duration) => { AND origin_id = '${feature.properties.id}' `); - previousStates.forEach(state => - map.setFeatureState( - { source: "protomap", sourceLayer: "tracts", id: state.id }, - { tract_color: "none" } - ) - ); - + wipeMapPreviousState(map, previousStates) previousStates = result.toArray().map(row => { - const destinationId = row.toJSON().destination_id; map.setFeatureState( - { source: "protomap", sourceLayer: "tracts", id: destinationId }, - { tract_color: colorScale(row.duration_sec) } + { source: "protomap", sourceLayer: "tracts", id: row.destination_id }, + { tract_color: getColorScale(row.duration_sec, map.getZoom()) } ); - return { id: destinationId }; + return { id: row.destination_id, duration: row.duration_sec }; }); spinner.hide(); } }); + + let previousZoomLevel = null; + map.on("zoom", () => { + const currentZoomLevel = map.getZoom(); + if (previousZoomLevel !== null) { + const crossedThreshold = zoomThresholds.some( + (threshold) => + (previousZoomLevel < threshold && currentZoomLevel >= threshold) || + (previousZoomLevel >= threshold && currentZoomLevel < threshold) + ); + + if (crossedThreshold) { + updateMapFill(map, previousStates); + colorScale.updateLabels(currentZoomLevel); + } + } + previousZoomLevel = currentZoomLevel; + }); })();