|
| 1 | +import './style.css'; |
| 2 | +import 'maplibre-gl/dist/maplibre-gl.css'; |
| 3 | +import maplibregl from 'maplibre-gl'; |
| 4 | +import {Deck, MapViewState} from '@deck.gl/core'; |
| 5 | +import {H3TileLayer, BASEMAP, colorBins} from '@deck.gl/carto'; |
| 6 | +import {initSelectors} from './selectorUtils'; |
| 7 | +import {debounce, getSpatialFilterFromViewState} from './utils'; |
| 8 | +import { |
| 9 | + addFilter, |
| 10 | + Filters, |
| 11 | + FilterType, |
| 12 | + h3QuerySource, |
| 13 | + removeFilter, |
| 14 | + WidgetSource |
| 15 | +} from '@carto/api-client'; |
| 16 | +import Chart from 'chart.js/auto'; |
| 17 | + |
| 18 | +const cartoConfig = { |
| 19 | + // @ts-expect-error misconfigured env variables |
| 20 | + apiBaseUrl: import.meta.env.VITE_API_BASE_URL, |
| 21 | + // @ts-expect-error misconfigured env variables |
| 22 | + accessToken: import.meta.env.VITE_API_ACCESS_TOKEN, |
| 23 | + connectionName: 'carto_dw' |
| 24 | +}; |
| 25 | + |
| 26 | +const INITIAL_VIEW_STATE: MapViewState = { |
| 27 | + latitude: 35.7128, |
| 28 | + longitude: -88.006, |
| 29 | + zoom: 5, |
| 30 | + pitch: 60, |
| 31 | + bearing: 0, |
| 32 | + minZoom: 3.5, |
| 33 | + maxZoom: 15 |
| 34 | +}; |
| 35 | + |
| 36 | +type Source = ReturnType<typeof h3QuerySource>; |
| 37 | + |
| 38 | +// Selectors variables |
| 39 | +let selectedVariable = 'population'; |
| 40 | +let aggregationExp = `SUM(${selectedVariable})`; |
| 41 | + |
| 42 | +let source: Source; |
| 43 | +let viewState = INITIAL_VIEW_STATE; |
| 44 | +const filters: Filters = {}; |
| 45 | + |
| 46 | +// DOM elements |
| 47 | +const variableSelector = document.getElementById('variable') as HTMLSelectElement; |
| 48 | +const formulaWidget = document.getElementById('formula-data') as HTMLDivElement; |
| 49 | +const histogramWidget = document.getElementById('histogram-data') as HTMLCanvasElement; |
| 50 | +const histogramClearBtn = document.querySelector( |
| 51 | + '.histogram-widget .clear-btn' |
| 52 | +) as HTMLButtonElement; |
| 53 | +histogramClearBtn.addEventListener('click', () => { |
| 54 | + removeFilter(filters, {column: 'urbanity'}); |
| 55 | + render(); |
| 56 | +}); |
| 57 | + |
| 58 | +let histogramChart: Chart; |
| 59 | + |
| 60 | +variableSelector?.addEventListener('change', () => { |
| 61 | + const aggMethod = variableSelector.selectedOptions[0].dataset.aggMethod || 'SUM'; |
| 62 | + |
| 63 | + selectedVariable = variableSelector.value; |
| 64 | + aggregationExp = `${aggMethod}(${selectedVariable})`; |
| 65 | + |
| 66 | + render(); |
| 67 | +}); |
| 68 | + |
| 69 | +function render() { |
| 70 | + source = h3QuerySource({ |
| 71 | + ...cartoConfig, |
| 72 | + filters, |
| 73 | + dataResolution: 8, |
| 74 | + aggregationExp: `${aggregationExp} as value, any_value(urbanity) as urbanity`, |
| 75 | + sqlQuery: |
| 76 | + 'SELECT * FROM cartobq.public_account.derived_spatialfeatures_usa_h3int_res8_v1_yearly_v2' |
| 77 | + }); |
| 78 | + renderWidgets(); |
| 79 | + renderLayers(); |
| 80 | +} |
| 81 | + |
| 82 | +function renderLayers() { |
| 83 | + const colorScale = colorBins({ |
| 84 | + attr: 'value', |
| 85 | + domain: [0, 100, 1000, 10000, 100000, 1000000], |
| 86 | + colors: 'PinkYl' |
| 87 | + }); |
| 88 | + |
| 89 | + const layers = [ |
| 90 | + new H3TileLayer({ |
| 91 | + id: 'h3_layer', |
| 92 | + data: source, |
| 93 | + opacity: 0.75, |
| 94 | + pickable: true, |
| 95 | + extruded: true, |
| 96 | + getFillColor: (...args) => { |
| 97 | + const color = colorScale(...args); |
| 98 | + const d = args[0]; |
| 99 | + const value = Math.floor(d.properties.value); |
| 100 | + if (value > 0) { |
| 101 | + return color; |
| 102 | + } |
| 103 | + return [0, 0, 0, 255 * 0.25]; |
| 104 | + }, |
| 105 | + getElevation: (...args) => { |
| 106 | + const d = args[0]; |
| 107 | + return d.properties.value; |
| 108 | + }, |
| 109 | + coverage: 0.95, |
| 110 | + elevationScale: 0.2, |
| 111 | + lineWidthMinPixels: 0.5, |
| 112 | + getLineWidth: 0.5, |
| 113 | + getLineColor: [255, 255, 255, 100] |
| 114 | + }) |
| 115 | + ]; |
| 116 | + |
| 117 | + deck.setProps({ |
| 118 | + layers, |
| 119 | + getTooltip: ({object}) => |
| 120 | + object && { |
| 121 | + html: `Hex ID: ${object.id}</br> |
| 122 | + ${selectedVariable.toUpperCase()}: ${Number(object.properties.value).toFixed(2)}</br> |
| 123 | + Urbanity: ${object.properties.urbanity}</br> |
| 124 | + Aggregation Expression: ${aggregationExp}` |
| 125 | + } |
| 126 | + }); |
| 127 | +} |
| 128 | + |
| 129 | +async function renderWidgets() { |
| 130 | + const {widgetSource} = await source; |
| 131 | + await Promise.all([renderFormula(widgetSource), renderHistogram(widgetSource)]); |
| 132 | +} |
| 133 | + |
| 134 | +async function renderFormula(ws: WidgetSource) { |
| 135 | + formulaWidget.innerHTML = '<span style="font-weight: 400; font-size: 14px;">Loading...</span>'; |
| 136 | + const formula = await ws.getFormula({ |
| 137 | + column: selectedVariable, |
| 138 | + operation: 'sum', |
| 139 | + spatialFilter: getSpatialFilterFromViewState(viewState), |
| 140 | + spatialIndexReferenceViewState: viewState |
| 141 | + }); |
| 142 | + formulaWidget.textContent = Intl.NumberFormat('en-US', { |
| 143 | + maximumFractionDigits: 0 |
| 144 | + // notation: 'compact' |
| 145 | + }).format(formula.value); |
| 146 | +} |
| 147 | + |
| 148 | +const HISTOGRAM_WIDGET_ID = 'urbanity_widget'; |
| 149 | + |
| 150 | +async function renderHistogram(ws: WidgetSource) { |
| 151 | + histogramWidget.parentElement?.querySelector('.loader')?.classList.toggle('hidden', false); |
| 152 | + histogramWidget.classList.toggle('hidden', true); |
| 153 | + |
| 154 | + const categories = await ws.getCategories({ |
| 155 | + column: 'urbanity', |
| 156 | + operation: 'sum', |
| 157 | + operationColumn: selectedVariable, |
| 158 | + filterOwner: HISTOGRAM_WIDGET_ID, |
| 159 | + spatialFilter: getSpatialFilterFromViewState(viewState), |
| 160 | + spatialIndexReferenceViewState: viewState |
| 161 | + }); |
| 162 | + |
| 163 | + histogramWidget.parentElement?.querySelector('.loader')?.classList.toggle('hidden', true); |
| 164 | + histogramWidget.classList.toggle('hidden', false); |
| 165 | + |
| 166 | + const selectedCategory = filters['urbanity']?.[FilterType.IN]?.values[0]; |
| 167 | + const colors = categories.map(c => |
| 168 | + c.name === selectedCategory ? 'rgba(255, 99, 132, 0.8)' : 'rgba(54, 162, 235, 0.75)' |
| 169 | + ); |
| 170 | + |
| 171 | + if (histogramChart) { |
| 172 | + histogramChart.data.labels = categories.map(c => c.name); |
| 173 | + histogramChart.data.datasets[0].data = categories.map(c => Math.floor(c.value)); |
| 174 | + histogramChart.data.datasets[0].backgroundColor = colors; |
| 175 | + histogramChart.update(); |
| 176 | + } else { |
| 177 | + histogramChart = new Chart(histogramWidget, { |
| 178 | + type: 'bar', |
| 179 | + data: { |
| 180 | + labels: categories.map(c => c.name), |
| 181 | + datasets: [ |
| 182 | + { |
| 183 | + label: 'Urbanity category', |
| 184 | + data: categories.map(c => Math.floor(c.value)), |
| 185 | + backgroundColor: colors |
| 186 | + } |
| 187 | + ] |
| 188 | + }, |
| 189 | + options: { |
| 190 | + onClick: async (ev, elems, chart) => { |
| 191 | + const labels = chart.data.labels as string[]; |
| 192 | + const index = elems[0]?.index; |
| 193 | + const categoryName = labels[index]; |
| 194 | + if (!categoryName || categoryName === selectedCategory) { |
| 195 | + removeFilter(filters, {column: 'urbanity'}); |
| 196 | + } else { |
| 197 | + addFilter(filters, { |
| 198 | + column: 'urbanity', |
| 199 | + type: FilterType.IN, |
| 200 | + values: [categoryName], |
| 201 | + owner: HISTOGRAM_WIDGET_ID |
| 202 | + }); |
| 203 | + } |
| 204 | + render(); |
| 205 | + } |
| 206 | + } |
| 207 | + }); |
| 208 | + } |
| 209 | +} |
| 210 | + |
| 211 | +const debouncedRenderWidgets = debounce(renderWidgets, 500); |
| 212 | + |
| 213 | +// Main execution |
| 214 | +const map = new maplibregl.Map({ |
| 215 | + container: 'map', |
| 216 | + style: BASEMAP.DARK_MATTER, |
| 217 | + interactive: false |
| 218 | +}); |
| 219 | + |
| 220 | +const deck = new Deck({ |
| 221 | + canvas: 'deck-canvas', |
| 222 | + initialViewState: viewState, |
| 223 | + controller: true |
| 224 | +}); |
| 225 | +deck.setProps({ |
| 226 | + onViewStateChange: props => { |
| 227 | + const {longitude, latitude, ...rest} = props.viewState; |
| 228 | + map.jumpTo({center: [longitude, latitude], ...rest}); |
| 229 | + viewState = props.viewState; |
| 230 | + debouncedRenderWidgets(); |
| 231 | + } |
| 232 | +}); |
| 233 | + |
| 234 | +initSelectors(); |
| 235 | +render(); |
0 commit comments