Skip to content

Commit c4339d5

Browse files
ryan-williamsclaude
andcommitted
UI polish: data-driven height scale, 3D toggle, gradient tuning, touch pitch fix
- Compute `heightScale` from actual data max so tallest bar = `maxHeight` (fixes wards appearing same height despite different $/sqft values) - Add `3d` URL param + checkbox to toggle extruded geometry vs flat colors - Add yellow-gold midpoint stop to all default color gradients (avoids grey muddle between red and green) - Fix two-finger pitch direction: drag up = increase pitch (toward surface) - Ward labels default on (`boolParam` flipped to default-true) - Fix ward labels / hover tooltip z-index stacking (behind settings panel) - Guard `setAggregateMode` against no-op re-selection (stuck grey loading) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 8a2a1ff commit c4339d5

3 files changed

Lines changed: 44 additions & 25 deletions

File tree

www/src/App.tsx

Lines changed: 38 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -113,20 +113,20 @@ const MODE_DEFAULTS: Record<string, ModeConfig> = {
113113
'lot': { max: 300, maxHeight: 4500 },
114114
'unit': { max: 300, maxHeight: 4500 },
115115
'census-block:per_sqft': { max: 20, maxHeight: 2000, stops: {
116-
dark: [{ value: 0, color: [96, 96, 96] }, { value: 1.5, color: [255, 0, 0] }, { value: 12, color: [0, 255, 0] }],
117-
light: [{ value: 0, color: [255, 255, 255] }, { value: 1.5, color: [255, 71, 71] }, { value: 12, color: [0, 214, 0] }],
116+
dark: [{ value: 0, color: [96, 96, 96] }, { value: 1.5, color: [255, 0, 0] }, { value: 4, color: [255, 217, 26] }, { value: 12, color: [0, 255, 0] }],
117+
light: [{ value: 0, color: [255, 255, 255] }, { value: 1.5, color: [255, 71, 71] }, { value: 4, color: [230, 190, 0] }, { value: 12, color: [0, 214, 0] }],
118118
}},
119119
'census-block:per_capita':{ max: 15000, maxHeight: 4500, scale: 'sqrt', stops: {
120-
dark: [{ value: 0, color: [96, 96, 96] }, { value: 3000, color: [255, 0, 0] }, { value: 10000, color: [0, 255, 0] }],
121-
light: [{ value: 0, color: [255, 255, 255] }, { value: 3000, color: [255, 71, 71] }, { value: 10000, color: [0, 214, 0] }],
120+
dark: [{ value: 0, color: [96, 96, 96] }, { value: 3000, color: [255, 0, 0] }, { value: 5500, color: [255, 217, 26] }, { value: 10000, color: [0, 255, 0] }],
121+
light: [{ value: 0, color: [255, 255, 255] }, { value: 3000, color: [255, 71, 71] }, { value: 5500, color: [230, 190, 0] }, { value: 10000, color: [0, 214, 0] }],
122122
}},
123123
'ward:per_sqft': { max: 10, maxHeight: 5500, scale: 'linear', stops: {
124-
dark: [{ value: 0, color: [96, 96, 96] }, { value: 4.9, color: [255, 0, 0] }, { value: 8.6, color: [0, 255, 0] }],
125-
light: [{ value: 0, color: [255, 255, 255] }, { value: 4.9, color: [255, 71, 71] }, { value: 8.6, color: [0, 214, 0] }],
124+
dark: [{ value: 0, color: [96, 96, 96] }, { value: 4.9, color: [255, 0, 0] }, { value: 6.5, color: [255, 217, 26] }, { value: 8.6, color: [0, 255, 0] }],
125+
light: [{ value: 0, color: [255, 255, 255] }, { value: 4.9, color: [255, 71, 71] }, { value: 6.5, color: [230, 190, 0] }, { value: 8.6, color: [0, 214, 0] }],
126126
}},
127127
'ward:per_capita': { max: 9000, maxHeight: 5400, scale: 'sqrt', stops: {
128-
dark: [{ value: 0, color: [96, 96, 96] }, { value: 1273.7, color: [255, 0, 0] }, { value: 6834.5, color: [0, 255, 0] }],
129-
light: [{ value: 0, color: [255, 255, 255] }, { value: 1273.7, color: [255, 71, 71] }, { value: 6834.5, color: [0, 214, 0] }],
128+
dark: [{ value: 0, color: [96, 96, 96] }, { value: 1273.7, color: [255, 0, 0] }, { value: 3500, color: [255, 217, 26] }, { value: 6834.5, color: [0, 255, 0] }],
129+
light: [{ value: 0, color: [255, 255, 255] }, { value: 1273.7, color: [255, 71, 71] }, { value: 3500, color: [230, 190, 0] }, { value: 6834.5, color: [0, 214, 0] }],
130130
}},
131131
}
132132
const SS_PREFIX = 'jc-taxes:'
@@ -149,8 +149,8 @@ const optScaleParam: Param<ScaleType | undefined> = {
149149
}
150150

151151
const boolParam: Param<boolean> = {
152-
decode: (s: string | undefined) => s === '1',
153-
encode: (v: boolean) => v ? '1' : undefined as unknown as string,
152+
decode: (s: string | undefined) => s !== '0',
153+
encode: (v: boolean) => v ? undefined as unknown as string : '0',
154154
}
155155

156156
const optNumParam: Param<number | undefined> = {
@@ -183,6 +183,7 @@ export default function App() {
183183
const [metricMode, setMetricModeRaw] = useUrlState('metric', stringParam('per_sqft'))
184184
const [wardGeom, setWardGeom] = useUrlState('wg', stringParam('merged'))
185185
const [wardLabels, setWardLabels] = useUrlState('wl', boolParam)
186+
const [extruded, setExtruded] = useUrlState('3d', boolParam)
186187

187188
const hasPopulation = aggregateMode === 'census-block' || aggregateMode === 'ward'
188189
const modeKey = getModeKey(aggregateMode, metricMode)
@@ -192,7 +193,15 @@ export default function App() {
192193
const maxVal = modeConf.max
193194
// Effective max height: URL value if explicitly set, else mode default
194195
const maxHeight = maxHeightRaw ?? modeConf.maxHeight
195-
const heightScale = maxHeight / modeConf.max
196+
// Height scale: tallest bar = maxHeight. Derived from actual data max.
197+
const dataMax = useMemo(() => {
198+
if (!data || data.length === 0) return modeConf.max
199+
const field = metricMode === 'per_capita' ? 'paid_per_capita' : 'paid_per_sqft'
200+
let mx = 0
201+
for (const f of data) mx = Math.max(mx, f.properties?.[field] ?? 0)
202+
return mx || modeConf.max
203+
}, [data, metricMode, modeConf.max])
204+
const heightScale = maxHeight / dataMax
196205
// Freeze height scale while loading to prevent stale data rendered with new-mode elevation
197206
const stableHeightScaleRef = useRef(heightScale)
198207
if (!loading) stableHeightScaleRef.current = heightScale
@@ -234,14 +243,15 @@ export default function App() {
234243
}, [aggregateMode, metricMode, maxHeight, maxHeightRaw, colorScaleRaw, hasCustomStops, resetColorStopsRaw])
235244

236245
const setAggregateMode = useCallback((newAgg: string) => {
246+
if (newAgg === aggregateMode) return
237247
setLoading(true)
238248
const newMetric = (newAgg === 'census-block' || newAgg === 'ward') ? metricMode : 'per_sqft'
239249
switchToMode(newAgg, newMetric)
240250
setAggregateModeRaw(newAgg)
241251
if (!(newAgg === 'census-block' || newAgg === 'ward') && metricMode === 'per_capita') {
242252
setMetricModeRaw('per_sqft')
243253
}
244-
}, [switchToMode, metricMode])
254+
}, [aggregateMode, switchToMode, metricMode])
245255

246256
const setMetricMode = useCallback((newMetric: string) => {
247257
switchToMode(aggregateMode, newMetric)
@@ -372,7 +382,7 @@ export default function App() {
372382

373383
for (let wi = 0; wi < wardLabelInfo.length; wi++) {
374384
const { metricVal, rings } = wardLabelInfo[wi]
375-
const elev = Math.min(metricVal * heightScale, maxHeight)
385+
const elev = metricVal * heightScale
376386

377387
for (const ring of rings) {
378388
const n = ring.length
@@ -587,10 +597,10 @@ export default function App() {
587597
id: 'parcels',
588598
data: effectiveData ?? [],
589599
filled: true,
590-
extruded: true,
591-
wireframe: true,
600+
extruded,
601+
wireframe: extruded,
592602
getFillColor,
593-
getElevation: (f) => Math.min(getMetricValue(f) * stableHeightScaleRef.current, maxHeight),
603+
getElevation: extruded ? (f) => getMetricValue(f) * stableHeightScaleRef.current : 0,
594604
getLineColor: lineColor,
595605
lineWidthMinPixels: 1,
596606
pickable: true,
@@ -617,7 +627,7 @@ export default function App() {
617627
},
618628
updateTriggers: {
619629
getFillColor: [year, maxVal, colorStops, colorScale, hoveredId, selectedId, aggregateMode, actualTheme, metricMode, staleData],
620-
getElevation: [year, stableHeightScaleRef.current, aggregateMode, metricMode, maxHeight],
630+
getElevation: [year, stableHeightScaleRef.current, aggregateMode, metricMode],
621631
getLineColor: [actualTheme],
622632
},
623633
}),
@@ -770,7 +780,15 @@ export default function App() {
770780
Ward labels
771781
</label>
772782
</>)}
773-
<label style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
783+
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
784+
<input
785+
type="checkbox"
786+
checked={extruded}
787+
onChange={(e) => setExtruded(e.target.checked)}
788+
/>
789+
3D
790+
</label>
791+
{extruded && <label style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
774792
Max height:{' '}
775793
<input
776794
type="number"
@@ -795,7 +813,7 @@ export default function App() {
795813
796814
</button>
797815
)}
798-
</label>
816+
</label>}
799817
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
800818
Pitch: {Math.round(viewState.pitch)}°
801819
<input
@@ -822,6 +840,7 @@ export default function App() {
822840
position: 'absolute',
823841
top: 10,
824842
left: 10,
843+
zIndex: 1,
825844
background: 'var(--panel-bg)',
826845
color: 'var(--text-primary)',
827846
padding: '10px 15px',
@@ -901,7 +920,6 @@ export default function App() {
901920
? '0 0 4px rgba(0,0,0,0.9), 0 0 8px rgba(0,0,0,0.6)'
902921
: '0 0 4px rgba(255,255,255,0.9), 0 0 8px rgba(255,255,255,0.6)',
903922
pointerEvents: 'none',
904-
zIndex: 2,
905923
}}
906924
>
907925
{label.text}

www/src/GradientEditor.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -355,11 +355,13 @@ function parseHex(hex: string): [number, number, number] {
355355
export const DEFAULT_STOPS_DARK: ColorStop[] = [
356356
{ value: 0, color: [96, 96, 96] },
357357
{ value: 3.8, color: [255, 0, 0] },
358-
{ value: 22.4, color: [0, 255, 0] },
358+
{ value: 9.9, color: [255, 217, 26] },
359+
{ value: 92.6, color: [0, 255, 0] },
359360
]
360361

361362
export const DEFAULT_STOPS_LIGHT: ColorStop[] = [
362363
{ value: 0, color: [255, 255, 255] },
363364
{ value: 3.8, color: [255, 71, 71] },
364-
{ value: 22.4, color: [0, 214, 0] },
365+
{ value: 9.9, color: [230, 190, 0] },
366+
{ value: 92.6, color: [0, 214, 0] },
365367
]

www/src/useTouchPitch.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,7 @@ import { useEffect, useRef, type Dispatch, type SetStateAction } from 'react'
1717
* Classification as "pitch" requires both fingers moving in the same vertical
1818
* direction with low spread change (not pinch) and low rotation.
1919
*
20-
* Convention: drag down = increase pitch (tilt toward horizon), matching
21-
* deck.gl's own `_onMultiPan` → `rotate` code path.
20+
* Convention: drag up = increase pitch (tilt toward horizon / surface).
2221
*
2322
* Usage:
2423
* ```tsx
@@ -108,7 +107,7 @@ export function useTouchPitch<V extends { pitch: number }>({
108107
e.preventDefault()
109108
const { maxPitch: mp, sensitivity: s } = cfgRef.current
110109
const frameDy = ((y0 - g.ly0) + (y1 - g.ly1)) / 2
111-
setterRef.current(v => ({ ...v, pitch: Math.max(0, Math.min(mp, v.pitch + frameDy * s)) } as V))
110+
setterRef.current(v => ({ ...v, pitch: Math.max(0, Math.min(mp, v.pitch - frameDy * s)) } as V))
112111
g.ly0 = y0; g.ly1 = y1
113112
}
114113

0 commit comments

Comments
 (0)