From 08da6780868e2c036fd5b472a5a91d3076ddb0d0 Mon Sep 17 00:00:00 2001 From: jakeross Date: Thu, 20 Aug 2026 21:26:20 -0700 Subject: [PATCH 1/3] feat(map): color map symbols with the viridis palette BDMS-349. Map symbol colors were an ad hoc mix of Material palette hues and ColorBrewer ramps. Neither was colorblind-safe, and several hues (brown, olive, dark blue) sank into satellite imagery. Everything now derives from a single viridis ramp: - src/constants/viridis.ts samples the reference ramp (viridisLite::viridis(10)) and interpolates between anchors, exposing viridisColor/viridisSamples/viridisGradient plus the ramp endpoints. - src/constants/mapColors.ts assigns every categorical layer an evenly spaced position on the ramp, and names the shared symbol colors (stroke, highlight, no-data fallback). - Classed layers (TDS, depth to water, water elevation) and their legend gradients sample the ramp in order, so darker always reads as lower. - The depth-to-water trend scale uses the ramp endpoints against its teal midpoint for the diverging declining/stable/rising cases. - No-data features keep a neutral gray, deliberately off-ramp so they cannot be mistaken for a real class. - Highlighted/selected symbols use ramp yellow over a dark purple stroke, the strongest contrast pair available inside the palette. A sequential ramp can only carry so many categories: with 23 layers the gap between adjacent entries is a few percent of the ramp, so enabling every layer at once still yields near neighbors. Ordering interleaves related families to keep commonly co-viewed layers far apart. --- .../ContactShow/AssociatedSitesMapCard.tsx | 8 +- .../card/InteractiveSatelliteMap.tsx | 16 +- src/constants/mapColors.ts | 91 ++++++++++ src/constants/viridis.ts | 102 +++++++++++ src/hooks/useLayer.tsx | 3 +- src/hooks/useOGCLayer.ts | 8 +- src/hooks/useThingLayers.tsx | 92 +++++----- src/pages/ocotillo/map/list.tsx | 17 +- .../thing/components/BatchRouteMap.tsx | 10 +- src/test/utils/viridis.test.ts | 164 ++++++++++++++++++ src/utils/ogcLayerUtils.ts | 69 ++++---- 11 files changed, 488 insertions(+), 92 deletions(-) create mode 100644 src/constants/mapColors.ts create mode 100644 src/constants/viridis.ts create mode 100644 src/test/utils/viridis.test.ts diff --git a/src/components/ContactShow/AssociatedSitesMapCard.tsx b/src/components/ContactShow/AssociatedSitesMapCard.tsx index 96475d77..f8543a5e 100644 --- a/src/components/ContactShow/AssociatedSitesMapCard.tsx +++ b/src/components/ContactShow/AssociatedSitesMapCard.tsx @@ -5,6 +5,10 @@ import { Layer, MapRef, Source } from 'react-map-gl/maplibre' import { Link } from '@refinedev/core' import type { IThing } from '@/interfaces/ocotillo' import { MapComponent } from '@/components' +import { + MAP_LAYER_COLORS, + MAP_SYMBOL_STROKE_COLOR, +} from '@/constants/mapColors' type AssociatedSitesMapCardProps = { things?: IThing[] | null @@ -195,8 +199,8 @@ export const AssociatedSitesMapCard = ({ things }: AssociatedSitesMapCardProps) type="circle" paint={{ 'circle-radius': 6, - 'circle-color': '#2b7dc0', - 'circle-stroke-color': '#ffffff', + 'circle-color': MAP_LAYER_COLORS.waterWells, + 'circle-stroke-color': MAP_SYMBOL_STROKE_COLOR, 'circle-stroke-width': 2, }} /> diff --git a/src/components/card/InteractiveSatelliteMap.tsx b/src/components/card/InteractiveSatelliteMap.tsx index 3dbdd4b9..4878d4c2 100644 --- a/src/components/card/InteractiveSatelliteMap.tsx +++ b/src/components/card/InteractiveSatelliteMap.tsx @@ -27,6 +27,12 @@ import { useGo } from '@refinedev/core' import { captureEvent } from '@/analytics/posthog' import { ColorModeContext } from '@/contexts' import { THEMED_BASEMAP_IDS } from '@/basemaps' +import { + MAP_HIGHLIGHT_COLOR, + MAP_HIGHLIGHT_STROKE_COLOR, + MAP_LAYER_COLORS, + MAP_SYMBOL_STROKE_COLOR, +} from '@/constants/mapColors' const MAP_HEIGHT = 450 @@ -316,8 +322,8 @@ const ProjectMapView = ({ type="circle" paint={{ 'circle-radius': 6, - 'circle-color': '#2b7dc0', - 'circle-stroke-color': '#ffffff', + 'circle-color': MAP_LAYER_COLORS.waterWells, + 'circle-stroke-color': MAP_SYMBOL_STROKE_COLOR, 'circle-stroke-width': 2, }} /> @@ -342,7 +348,7 @@ const WellMapView = ({ well }: { well: IWell }) => { const waterWellsLayer = useLayer({ thing_type: 'water well', label: 'Water Wells', - color: '#2b7dc0', + color: MAP_LAYER_COLORS.waterWells, enabled: loadNearbyWells, }) const [popupContent, setPopupContent] = useState(null) @@ -544,8 +550,8 @@ const WellMapView = ({ well }: { well: IWell }) => { type="circle" paint={{ 'circle-radius': 6, - 'circle-color': '#ff4d4d', - 'circle-stroke-color': '#ffffff', + 'circle-color': MAP_HIGHLIGHT_COLOR, + 'circle-stroke-color': MAP_HIGHLIGHT_STROKE_COLOR, 'circle-stroke-width': 2, }} /> diff --git a/src/constants/mapColors.ts b/src/constants/mapColors.ts new file mode 100644 index 00000000..0e25cfe6 --- /dev/null +++ b/src/constants/mapColors.ts @@ -0,0 +1,91 @@ +// --------------------------------------------------------------------------- +// Map symbol colors +// +// Every point, line, and polygon color on the map comes from the viridis ramp +// (see ./viridis). Two kinds of styling use it differently: +// +// * Classed/continuous layers (TDS, depth to water, water elevation) sample +// the ramp in order, so darker always means lower and yellow means higher. +// * Categorical layers (wells, springs, streams, ...) each pin one fixed +// position on the ramp. Positions are spaced evenly, and the order below +// interleaves related families — wells next to chemistry next to surface +// water — so two layers a user is likely to view together land far apart on +// the ramp and stay easy to tell apart. +// +// Note the inherent limit: a sequential ramp can only carry so many +// categories. With this many layers the gap between adjacent entries is a few +// percent of the ramp, so switching on every layer at once still produces near +// neighbors. In normal use only a handful are visible together. +// --------------------------------------------------------------------------- + +import { VIRIDIS_HIGH, VIRIDIS_LOW, viridisColor } from './viridis' + +/** + * Categorical map layers in ramp order: the first entry gets the dark purple + * end, the last gets yellow, and the rest are spread evenly in between. + */ +const LAYER_RAMP_ORDER = [ + 'locations', + 'waterElevationContours', + 'lakesPondsReservoirs', + 'waterWells', + 'waterElevationPoints', + 'latestDepthToWater', + 'waterWellSummary', + 'perennialStreams', + 'springs', + 'activelyMonitored', + 'meteorologicalStations', + 'latestTds', + 'majorChemistry', + 'averageTds', + 'outfallsReturnFlow', + 'ephemeralStreams', + 'rockSampleLocations', + 'minorChemistry', + 'depthToWaterTrend', + 'surfaceWaterDiversions', + 'projectAreas', + 'soilGasSampleLocations', + 'otherThingTypes', +] as const + +type MapLayerColorKey = (typeof LAYER_RAMP_ORDER)[number] + +/** Viridis hex color for each categorical map layer. */ +export const MAP_LAYER_COLORS = Object.fromEntries( + LAYER_RAMP_ORDER.map((key, index) => [ + key, + viridisColor(index / (LAYER_RAMP_ORDER.length - 1)), + ]) +) as Record + +/** + * Fallback for a layer with no color assigned. Sits mid-ramp so it reads as + * part of the same family rather than as an outlier. + */ +export const MAP_DEFAULT_LAYER_COLOR = viridisColor(0.5) + +/** + * Features in a classed layer whose value is missing or unparseable. Kept + * deliberately outside the viridis ramp so "no data" never looks like a real + * class on the legend gradient. + */ +export const MAP_NO_DATA_COLOR = '#9e9e9e' + +/** White outline that lifts every symbol off the satellite basemap. */ +export const MAP_SYMBOL_STROKE_COLOR = '#ffffff' + +/** + * Translucent white disc drawn behind a highlighted symbol. Neutral on + * purpose — it has to read as a halo, not as another data class. + */ +export const MAP_HIGHLIGHT_HALO_COLOR = '#ffffff' + +/** + * The selected/active symbol. Yellow fill against the dark end of the ramp + * gives the strongest contrast available inside the palette, so a highlighted + * point stands out from both the other points and the imagery underneath. + */ +export const MAP_HIGHLIGHT_COLOR = VIRIDIS_HIGH +export const MAP_HIGHLIGHT_STROKE_COLOR = VIRIDIS_LOW diff --git a/src/constants/viridis.ts b/src/constants/viridis.ts new file mode 100644 index 00000000..3918aa93 --- /dev/null +++ b/src/constants/viridis.ts @@ -0,0 +1,102 @@ +// --------------------------------------------------------------------------- +// Viridis color palette +// +// Viridis is a perceptually uniform, colorblind-friendly colormap: equal steps +// in the data produce equal-looking steps in color, and the ramp reads the same +// under the common forms of color vision deficiency. It also stays legible on +// satellite imagery because it runs dark-purple -> teal -> green -> yellow, +// none of which collide with the browns and grays of aerial photography. +// +// Everything here is derived from ten evenly spaced anchors taken from the +// reference implementation (viridisLite::viridis(10)). Colors between anchors +// are linearly interpolated, which tracks the full 256-entry reference map +// closely enough to be visually indistinguishable at map-symbol sizes. +// +// Reference: https://sjmgarnier.github.io/viridisLite/reference/viridis.html +// --------------------------------------------------------------------------- + +/** Evenly spaced samples of the reference viridis ramp, dark end first. */ +export const VIRIDIS_ANCHORS = [ + '#440154', + '#482878', + '#3e4a89', + '#31688e', + '#26828e', + '#1f9e89', + '#35b779', + '#6dcd59', + '#b4de2c', + '#fde725', +] as const + +const clamp01 = (value: number): number => { + if (!Number.isFinite(value)) return 0 + if (value < 0) return 0 + if (value > 1) return 1 + return value +} + +const hexToRgb = (hex: string): [number, number, number] => [ + Number.parseInt(hex.slice(1, 3), 16), + Number.parseInt(hex.slice(3, 5), 16), + Number.parseInt(hex.slice(5, 7), 16), +] + +const channelToHex = (value: number): string => + Math.round(value).toString(16).padStart(2, '0') + +/** + * Color at `position` along the viridis ramp, where 0 is the dark purple end + * and 1 is the bright yellow end. Values outside 0..1 (and non-finite values) + * are clamped so callers never have to sanitize computed ratios. + */ +export const viridisColor = (position: number): string => { + const scaled = clamp01(position) * (VIRIDIS_ANCHORS.length - 1) + const lowerIndex = Math.floor(scaled) + const upperIndex = Math.min(VIRIDIS_ANCHORS.length - 1, lowerIndex + 1) + const fraction = scaled - lowerIndex + + const [r1, g1, b1] = hexToRgb(VIRIDIS_ANCHORS[lowerIndex]) + const [r2, g2, b2] = hexToRgb(VIRIDIS_ANCHORS[upperIndex]) + + const r = r1 + (r2 - r1) * fraction + const g = g1 + (g2 - g1) * fraction + const b = b1 + (b2 - b1) * fraction + + return `#${channelToHex(r)}${channelToHex(g)}${channelToHex(b)}` +} + +/** + * `count` colors spread evenly across the ramp, including both endpoints. + * Use this for binned/classed styling — e.g. six TDS classes get + * `viridisSamples(6)`, dark for the lowest class through yellow for the highest. + */ +export const viridisSamples = (count: number): string[] => { + if (count <= 0) return [] + if (count === 1) return [viridisColor(0.5)] + return Array.from({ length: count }, (_, index) => + viridisColor(index / (count - 1)) + ) +} + +/** + * A CSS `linear-gradient` across the ramp, for legend swatches. More stops + * means a smoother gradient; ten matches the anchor resolution. + */ +export const viridisGradient = (stopCount = 10, angle = '90deg'): string => { + const samples = viridisSamples(Math.max(2, stopCount)) + const stops = samples.map( + (color, index) => + `${color} ${Math.round((index / (samples.length - 1)) * 100)}%` + ) + return `linear-gradient(${angle}, ${stops.join(', ')})` +} + +/** Dark purple end of the ramp — lowest values. */ +export const VIRIDIS_LOW = VIRIDIS_ANCHORS[0] + +/** Teal middle of the ramp — mid values, and the neutral in diverging scales. */ +export const VIRIDIS_MID = viridisColor(0.5) + +/** Bright yellow end of the ramp — highest values. */ +export const VIRIDIS_HIGH = VIRIDIS_ANCHORS[VIRIDIS_ANCHORS.length - 1] diff --git a/src/hooks/useLayer.tsx b/src/hooks/useLayer.tsx index 810be45e..77f936d5 100644 --- a/src/hooks/useLayer.tsx +++ b/src/hooks/useLayer.tsx @@ -1,4 +1,5 @@ import { useOne } from '@refinedev/core' +import { MAP_SYMBOL_STROKE_COLOR } from '@/constants/mapColors' export const useLayer = ({ thing_type, @@ -46,7 +47,7 @@ export const useLayer = ({ paint: { 'circle-radius': 3, 'circle-color': color, - 'circle-stroke-color': '#ffffff', + 'circle-stroke-color': MAP_SYMBOL_STROKE_COLOR, 'circle-stroke-width': 1, }, }, diff --git a/src/hooks/useOGCLayer.ts b/src/hooks/useOGCLayer.ts index 9e3e4490..3ba8d7d1 100644 --- a/src/hooks/useOGCLayer.ts +++ b/src/hooks/useOGCLayer.ts @@ -4,6 +4,10 @@ import { useQuery } from '@tanstack/react-query' import { captureEvent } from '@/analytics/posthog' import { withRetry } from '@/utils/httpRetry' import { DEFAULT_TEXT_FONT } from '@/basemaps' +import { + MAP_DEFAULT_LAYER_COLOR, + MAP_SYMBOL_STROKE_COLOR, +} from '@/constants/mapColors' // --------------------------------------------------------------------------- // useOGCLayer @@ -233,7 +237,7 @@ export const useOGCLayer = ({ collection, label, providerName = 'ogcapi', - color = '#9cd0ab', + color = MAP_DEFAULT_LAYER_COLOR, colorAccessor, textAccessor, textColor = '#111111', @@ -427,7 +431,7 @@ export const useOGCLayer = ({ circle: { 'circle-radius': 3, 'circle-color': effectiveColor, - 'circle-stroke-color': '#ffffff', + 'circle-stroke-color': MAP_SYMBOL_STROKE_COLOR, 'circle-stroke-width': 1, }, line: { diff --git a/src/hooks/useThingLayers.tsx b/src/hooks/useThingLayers.tsx index 1c72bde9..cb40f9de 100644 --- a/src/hooks/useThingLayers.tsx +++ b/src/hooks/useThingLayers.tsx @@ -3,6 +3,15 @@ import { useQuery } from '@tanstack/react-query' import { useMemo } from 'react' import * as turf from '@turf/turf' import { useOGCLayer } from '@/hooks/useOGCLayer' +import { + MAP_LAYER_COLORS, + MAP_NO_DATA_COLOR, +} from '@/constants/mapColors' +import { + VIRIDIS_LOW, + viridisGradient, + viridisSamples, +} from '@/constants/viridis' import { parseNumeric } from '@/utils/parseNumeric' import { getWaterElevationFeet, @@ -22,12 +31,15 @@ import { } from '@/utils/ogcLayerUtils' const WATER_ELEVATION_LEGEND = { - gradient: - 'linear-gradient(90deg, #2c7bb6 0%, #00a6ca 20%, #00ccbc 40%, #90eb9d 55%, #ffff8c 70%, #f9d057 82%, #f29e2e 92%, #d7191c 100%)', + gradient: viridisGradient(), minLabel: 'Lower (ft)', maxLabel: 'Higher (ft)', } +// Seven classes across the viridis ramp: dark purple for the lowest water +// elevations through yellow for the highest. +const waterElevationColors = viridisSamples(7) + const EMPTY_FEATURE_COLLECTION = { type: 'FeatureCollection', features: [], @@ -281,14 +293,14 @@ export const useThingLayers = ( const locationsLayer = useOGCLayer({ collection: locations.id, label: locations.label, - color: '#607d8b', + color: MAP_LAYER_COLORS.locations, enabled: locations.exists && isLayerActive('ogc-locations'), }) const latestDepthToWaterLayer = useOGCLayer({ collection: latestDepthToWater.id, label: latestDepthToWater.label, - legendColor: '#fdae61', - color: '#9e9e9e', + legendColor: MAP_LAYER_COLORS.latestDepthToWater, + color: MAP_NO_DATA_COLOR, colorAccessor: latestDepthToWaterColorFromFeature, legendScale: DEPTH_LEGEND, colorMappingEnabled: isColorMappingEnabled('ogc-latest-depth-to-water'), @@ -298,8 +310,8 @@ export const useThingLayers = ( const averageTdsLayer = useOGCLayer({ collection: averageTds.id, label: averageTds.label, - legendColor: '#f46d43', - color: '#9e9e9e', + legendColor: MAP_LAYER_COLORS.averageTds, + color: MAP_NO_DATA_COLOR, colorAccessor: averageTdsColorFromFeature, legendScale: TDS_LEGEND, colorMappingEnabled: isColorMappingEnabled('ogc-average-tds'), @@ -308,8 +320,8 @@ export const useThingLayers = ( const latestTdsLayer = useOGCLayer({ collection: latestTds.id, label: latestTds.label, - legendColor: '#fdae61', - color: '#9e9e9e', + legendColor: MAP_LAYER_COLORS.latestTds, + color: MAP_NO_DATA_COLOR, colorAccessor: latestTdsColorFromFeature, legendScale: TDS_LEGEND, colorMappingEnabled: isColorMappingEnabled('ogc-latest-tds'), @@ -318,20 +330,20 @@ export const useThingLayers = ( const majorChemistryLayer = useOGCLayer({ collection: majorChemistry.id, label: majorChemistry.label, - color: '#8e24aa', + color: MAP_LAYER_COLORS.majorChemistry, enabled: majorChemistry.exists && isLayerActive('ogc-major-chemistry'), }) const minorChemistryLayer = useOGCLayer({ collection: minorChemistry.id, label: minorChemistry.label, - color: '#6a1b9a', + color: MAP_LAYER_COLORS.minorChemistry, enabled: minorChemistry.exists && isLayerActive('ogc-minor-chemistry'), }) const depthToWaterTrendLayer = useOGCLayer({ collection: depthToWaterTrend.id, label: depthToWaterTrend.label, - legendColor: '#b2182b', - color: '#9e9e9e', + legendColor: MAP_LAYER_COLORS.depthToWaterTrend, + color: MAP_NO_DATA_COLOR, colorAccessor: trendColorFromFeature, legendScale: TREND_LEGEND, colorMappingEnabled: isColorMappingEnabled('ogc-depth-to-water-trend'), @@ -341,32 +353,32 @@ export const useThingLayers = ( const waterWellSummaryLayer = useOGCLayer({ collection: waterWellSummary.id, label: waterWellSummary.label, - color: '#8bc34a', + color: MAP_LAYER_COLORS.waterWellSummary, enabled: waterWellSummary.exists && isLayerActive('ogc-water-well-summary'), }) const waterWellsLayer = useOGCLayer({ collection: waterWells.id, label: waterWells.label, - color: '#2b7dc0', + color: MAP_LAYER_COLORS.waterWells, enabled: waterWells.exists && isLayerActive('ogc-water-wells'), }) const activelyMonitoredLayer = useOGCLayer({ collection: activelyMonitored.id, label: activelyMonitored.label, - color: '#2e7d32', + color: MAP_LAYER_COLORS.activelyMonitored, enabled: activelyMonitored.exists && isLayerActive('ogc-actively-monitored'), }) const springsLayer = useOGCLayer({ collection: springs.id, label: springs.label, - color: '#00acc1', + color: MAP_LAYER_COLORS.springs, enabled: springs.exists && isLayerActive('ogc-springs'), }) const waterElevationContoursLayer = useOGCLayer({ collection: waterElevationContours.id, label: waterElevationContours.label, - color: '#0d47a1', + color: MAP_LAYER_COLORS.waterElevationContours, layerType: 'line', paint: { 'line-width': 1.2, @@ -384,7 +396,7 @@ export const useThingLayers = ( const waterElevationPointsLayer = useOGCLayer({ collection: waterElevationPoints.id, label: `${waterElevationPoints.label} (ft)`, - color: '#1976d2', + color: MAP_LAYER_COLORS.waterElevationPoints, legendScale: WATER_ELEVATION_LEGEND, colorMappingEnabled: isColorMappingEnabled('ogc-water-elevation-points'), enabled: waterElevationPoints.exists, @@ -430,16 +442,6 @@ export const useThingLayers = ( [waterElevationPointFeatures] ) - const waterElevationColors = [ - '#2c7bb6', - '#00a6ca', - '#00ccbc', - '#90eb9d', - '#ffff8c', - '#f29e2e', - '#d7191c', - ] - const buildWaterElevationStepExpression = (propertyName: string): any => waterElevationStats.hasSpread ? [ @@ -459,7 +461,7 @@ export const useThingLayers = ( waterElevationStats.breaks[5], waterElevationColors[6], ] - : '#1976d2' + : MAP_LAYER_COLORS.waterElevationPoints const waterElevationColorExpression = useMemo( () => buildWaterElevationStepExpression('water_elevation_ft'), @@ -506,7 +508,7 @@ export const useThingLayers = ( ...(waterElevationPointsLayer.layerProps?.paint || {}), 'circle-color': isWaterElevationPointsColorMapped ? waterElevationColorExpression - : '#1976d2', + : MAP_LAYER_COLORS.waterElevationPoints, }, }, } @@ -532,7 +534,7 @@ export const useThingLayers = ( ...(waterElevationContoursLayer.layerProps?.paint || {}), 'line-color': isWaterElevationContoursColorMapped ? waterElevationColorExpression - : '#0d47a1', + : MAP_LAYER_COLORS.waterElevationContours, }, }, }), @@ -721,7 +723,7 @@ export const useThingLayers = ( legendScale: isWaterElevationDerivedContoursColorMapped ? waterElevationLegendScale : undefined, - legendColor: '#0d47a1', + legendColor: MAP_LAYER_COLORS.waterElevationContours, colorMappingAvailable: true, colorMappingEnabled: isWaterElevationDerivedContoursColorMapped, layerProps: { @@ -730,7 +732,7 @@ export const useThingLayers = ( paint: { 'line-color': isWaterElevationDerivedContoursColorMapped ? waterElevationColorExpression - : '#0d47a1', + : MAP_LAYER_COLORS.waterElevationContours, 'line-width': 1.2, 'line-opacity': 0.85, }, @@ -776,7 +778,7 @@ export const useThingLayers = ( const surfaceWaterDiversionsLayer = useOGCLayer({ collection: surfaceWaterDiversions.id, label: surfaceWaterDiversions.label, - color: '#ef6c00', + color: MAP_LAYER_COLORS.surfaceWaterDiversions, enabled: surfaceWaterDiversions.exists && isLayerActive('ogc-surface-water-diversions'), @@ -784,13 +786,13 @@ export const useThingLayers = ( const ephemeralStreamsLayer = useOGCLayer({ collection: ephemeralStreams.id, label: ephemeralStreams.label, - color: '#8e24aa', + color: MAP_LAYER_COLORS.ephemeralStreams, enabled: ephemeralStreams.exists && isLayerActive('ogc-ephemeral-streams'), }) const lakesPondsReservoirsLayer = useOGCLayer({ collection: lakesPondsReservoirs.id, label: lakesPondsReservoirs.label, - color: '#3949ab', + color: MAP_LAYER_COLORS.lakesPondsReservoirs, enabled: lakesPondsReservoirs.exists && isLayerActive('ogc-lakes-ponds-reservoirs'), @@ -798,7 +800,7 @@ export const useThingLayers = ( const meteorologicalStationsLayer = useOGCLayer({ collection: meteorologicalStations.id, label: meteorologicalStations.label, - color: '#546e7a', + color: MAP_LAYER_COLORS.meteorologicalStations, enabled: meteorologicalStations.exists && isLayerActive('ogc-meteorological-stations'), @@ -806,44 +808,44 @@ export const useThingLayers = ( const projectAreasLayer = useOGCLayer({ collection: projectAreas.id, label: 'AMP Project Areas', - color: '#7c3aed', + color: MAP_LAYER_COLORS.projectAreas, layerType: 'fill', paint: { 'fill-opacity': 0.16, - 'fill-outline-color': '#5b21b6', + 'fill-outline-color': VIRIDIS_LOW, }, enabled: projectAreas.exists && isLayerActive('ogc-project-areas'), }) const otherThingTypesLayer = useOGCLayer({ collection: otherThingTypes.id, label: otherThingTypes.label, - color: '#9e9d24', + color: MAP_LAYER_COLORS.otherThingTypes, enabled: otherThingTypes.exists && isLayerActive('ogc-other-thing-types'), }) const outfallsReturnFlowLayer = useOGCLayer({ collection: outfallsReturnFlow.id, label: outfallsReturnFlow.label, - color: '#5d4037', + color: MAP_LAYER_COLORS.outfallsReturnFlow, enabled: outfallsReturnFlow.exists && isLayerActive('ogc-outfalls-return-flow'), }) const perennialStreamsLayer = useOGCLayer({ collection: perennialStreams.id, label: perennialStreams.label, - color: '#1e88e5', + color: MAP_LAYER_COLORS.perennialStreams, enabled: perennialStreams.exists && isLayerActive('ogc-perennial-streams'), }) const rockSampleLocationsLayer = useOGCLayer({ collection: rockSampleLocations.id, label: rockSampleLocations.label, - color: '#6d4c41', + color: MAP_LAYER_COLORS.rockSampleLocations, enabled: rockSampleLocations.exists && isLayerActive('ogc-rock-sample-locations'), }) const soilGasSampleLocationsLayer = useOGCLayer({ collection: soilGasSampleLocations.id, label: soilGasSampleLocations.label, - color: '#7cb342', + color: MAP_LAYER_COLORS.soilGasSampleLocations, enabled: soilGasSampleLocations.exists && isLayerActive('ogc-soil-gas-sample-locations'), diff --git a/src/pages/ocotillo/map/list.tsx b/src/pages/ocotillo/map/list.tsx index 6f2eae0e..77f116ed 100644 --- a/src/pages/ocotillo/map/list.tsx +++ b/src/pages/ocotillo/map/list.tsx @@ -40,6 +40,13 @@ import { import { MapPopup } from '@/components' import { useMeasuredHeight, useThingLayers, useViewportBbox } from '@/hooks' import { DEFAULT_BASEMAP_ID } from '@/basemaps' +import { + MAP_HIGHLIGHT_COLOR, + MAP_HIGHLIGHT_HALO_COLOR, + MAP_HIGHLIGHT_STROKE_COLOR, + MAP_NO_DATA_COLOR, + MAP_SYMBOL_STROKE_COLOR, +} from '@/constants/mapColors' import { buildLayerCsv, filterLayerFeaturesBySelection, @@ -997,9 +1004,9 @@ export const MapView: React.FC = () => { type="circle" paint={{ 'circle-radius': 10, - 'circle-color': '#ffffff', + 'circle-color': MAP_HIGHLIGHT_HALO_COLOR, 'circle-opacity': 0.22, - 'circle-stroke-color': '#0f172a', + 'circle-stroke-color': MAP_HIGHLIGHT_STROKE_COLOR, 'circle-stroke-width': 2.4, }} /> @@ -1008,8 +1015,8 @@ export const MapView: React.FC = () => { type="circle" paint={{ 'circle-radius': 6, - 'circle-color': '#2563eb', - 'circle-stroke-color': '#ffffff', + 'circle-color': MAP_HIGHLIGHT_COLOR, + 'circle-stroke-color': MAP_SYMBOL_STROKE_COLOR, 'circle-stroke-width': 1.8, }} /> @@ -1301,7 +1308,7 @@ export const MapView: React.FC = () => { layerDef.legendColor || (typeof paintColor === 'string' ? paintColor - : '#9e9e9e') + : MAP_NO_DATA_COLOR) const description = typeof layerDef.description === 'string' ? layerDef.description.trim() diff --git a/src/pages/ocotillo/thing/components/BatchRouteMap.tsx b/src/pages/ocotillo/thing/components/BatchRouteMap.tsx index 6d94ca58..276bbc0e 100644 --- a/src/pages/ocotillo/thing/components/BatchRouteMap.tsx +++ b/src/pages/ocotillo/thing/components/BatchRouteMap.tsx @@ -5,6 +5,10 @@ import { Layer, Source } from 'react-map-gl/maplibre' import type { FeatureCollection, Geometry } from 'geojson' import type { IWell } from '@/interfaces/ocotillo' import MapComponent from '@/components/MapComponent' +import { + MAP_LAYER_COLORS, + MAP_SYMBOL_STROKE_COLOR, +} from '@/constants/mapColors' export const BatchRouteMap = ({ wells }: { wells: IWell[] }) => { const containerRef = useRef(null) @@ -149,8 +153,8 @@ export const BatchRouteMap = ({ wells }: { wells: IWell[] }) => { type="circle" filter={['==', ['geometry-type'], 'Point']} paint={{ - 'circle-color': '#1a5276', - 'circle-stroke-color': '#ffffff', + 'circle-color': MAP_LAYER_COLORS.locations, + 'circle-stroke-color': MAP_SYMBOL_STROKE_COLOR, 'circle-stroke-width': 2, 'circle-radius': 8, }} @@ -168,7 +172,7 @@ export const BatchRouteMap = ({ wells }: { wells: IWell[] }) => { 'text-allow-overlap': true, }} paint={{ - 'text-color': '#1a5276', + 'text-color': MAP_LAYER_COLORS.locations, 'text-halo-color': '#ffffff', 'text-halo-width': 1, }} diff --git a/src/test/utils/viridis.test.ts b/src/test/utils/viridis.test.ts new file mode 100644 index 00000000..bf35735d --- /dev/null +++ b/src/test/utils/viridis.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from 'vitest' +import { + MAP_DEFAULT_LAYER_COLOR, + MAP_HIGHLIGHT_COLOR, + MAP_LAYER_COLORS, + MAP_NO_DATA_COLOR, +} from '@/constants/mapColors' +import { + VIRIDIS_ANCHORS, + VIRIDIS_HIGH, + VIRIDIS_LOW, + VIRIDIS_MID, + viridisColor, + viridisGradient, + viridisSamples, +} from '@/constants/viridis' + +const HEX = /^#[0-9a-f]{6}$/ + +const DENSE_RAMP = viridisSamples(1001) + +const rgb = (hex: string): [number, number, number] => [ + Number.parseInt(hex.slice(1, 3), 16), + Number.parseInt(hex.slice(3, 5), 16), + Number.parseInt(hex.slice(5, 7), 16), +] + +/** Largest per-channel gap between `hex` and the nearest color on the ramp. */ +const distanceToRamp = (hex: string): number => { + const [r, g, b] = rgb(hex) + return DENSE_RAMP.reduce((best, sample) => { + const [sr, sg, sb] = rgb(sample) + const gap = Math.max(Math.abs(r - sr), Math.abs(g - sg), Math.abs(b - sb)) + return Math.min(best, gap) + }, Number.POSITIVE_INFINITY) +} + +const relativeLuminance = (hex: string): number => { + const channel = (offset: number) => { + const value = Number.parseInt(hex.slice(offset, offset + 2), 16) / 255 + return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4 + } + return 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5) +} + +describe('viridisColor', () => { + it('returns the ramp endpoints at 0 and 1', () => { + expect(viridisColor(0)).toBe('#440154') + expect(viridisColor(1)).toBe('#fde725') + }) + + it('returns an anchor exactly when the position lands on one', () => { + VIRIDIS_ANCHORS.forEach((anchor, index) => { + expect(viridisColor(index / (VIRIDIS_ANCHORS.length - 1))).toBe(anchor) + }) + }) + + it('interpolates between anchors', () => { + // Halfway between #440154 and #482878. + expect(viridisColor(0.5 / (VIRIDIS_ANCHORS.length - 1))).toBe('#461566') + }) + + it('clamps out-of-range and non-finite positions', () => { + expect(viridisColor(-3)).toBe(VIRIDIS_LOW) + expect(viridisColor(42)).toBe(VIRIDIS_HIGH) + expect(viridisColor(Number.NaN)).toBe(VIRIDIS_LOW) + expect(viridisColor(Number.POSITIVE_INFINITY)).toBe(VIRIDIS_LOW) + }) + + it('always produces a six-digit lowercase hex color', () => { + for (let step = 0; step <= 100; step += 1) { + expect(viridisColor(step / 100)).toMatch(HEX) + } + }) + + it('increases monotonically in luminance from the dark to the light end', () => { + const luminances = viridisSamples(20).map(relativeLuminance) + for (let index = 1; index < luminances.length; index += 1) { + expect(luminances[index]).toBeGreaterThan(luminances[index - 1]) + } + }) +}) + +describe('viridisSamples', () => { + it('returns an empty list for non-positive counts', () => { + expect(viridisSamples(0)).toEqual([]) + expect(viridisSamples(-1)).toEqual([]) + }) + + it('returns the ramp midpoint for a single sample', () => { + expect(viridisSamples(1)).toEqual([VIRIDIS_MID]) + }) + + it('includes both endpoints and stays distinct for typical class counts', () => { + for (const count of [3, 6, 7]) { + const samples = viridisSamples(count) + expect(samples).toHaveLength(count) + expect(samples[0]).toBe(VIRIDIS_LOW) + expect(samples[count - 1]).toBe(VIRIDIS_HIGH) + expect(new Set(samples).size).toBe(count) + } + }) +}) + +describe('viridisGradient', () => { + it('spans 0% to 100% across the ramp', () => { + const gradient = viridisGradient(3) + expect(gradient).toBe( + `linear-gradient(90deg, ${VIRIDIS_LOW} 0%, ${VIRIDIS_MID} 50%, ${VIRIDIS_HIGH} 100%)` + ) + }) + + it('never emits fewer than two stops', () => { + expect(viridisGradient(1)).toBe( + `linear-gradient(90deg, ${VIRIDIS_LOW} 0%, ${VIRIDIS_HIGH} 100%)` + ) + }) + + it('accepts a custom angle', () => { + expect(viridisGradient(2, '180deg')).toContain('linear-gradient(180deg,') + }) +}) + +describe('map layer colors', () => { + it('gives every layer a distinct viridis color', () => { + const colors = Object.values(MAP_LAYER_COLORS) + expect(colors.length).toBeGreaterThan(0) + colors.forEach((color) => expect(color).toMatch(HEX)) + expect(new Set(colors).size).toBe(colors.length) + }) + + it('draws every layer color from the viridis ramp', () => { + Object.values(MAP_LAYER_COLORS).forEach((color) => { + expect(distanceToRamp(color)).toBeLessThanOrEqual(1) + }) + }) + + it('keeps the no-data color off the ramp so it cannot read as a class', () => { + expect(distanceToRamp(MAP_NO_DATA_COLOR)).toBeGreaterThan(8) + }) + + it('spaces layer colors apart instead of clustering them', () => { + const colors = Object.values(MAP_LAYER_COLORS) + let closest = Number.POSITIVE_INFINITY + for (let i = 0; i < colors.length; i += 1) { + for (let j = i + 1; j < colors.length; j += 1) { + const [r1, g1, b1] = rgb(colors[i]) + const [r2, g2, b2] = rgb(colors[j]) + closest = Math.min( + closest, + Math.max(Math.abs(r1 - r2), Math.abs(g1 - g2), Math.abs(b1 - b2)) + ) + } + } + // Guards against a future layer being squeezed in next to an existing one: + // no two layer colors may sit within 8/255 on every channel. + expect(closest).toBeGreaterThanOrEqual(8) + }) + + it('highlights with the light end of the ramp for maximum contrast', () => { + expect(MAP_HIGHLIGHT_COLOR).toBe(VIRIDIS_HIGH) + expect(MAP_DEFAULT_LAYER_COLOR).toBe(VIRIDIS_MID) + }) +}) diff --git a/src/utils/ogcLayerUtils.ts b/src/utils/ogcLayerUtils.ts index 1d7b307e..3b0a173e 100644 --- a/src/utils/ogcLayerUtils.ts +++ b/src/utils/ogcLayerUtils.ts @@ -1,3 +1,16 @@ +import { + VIRIDIS_HIGH, + VIRIDIS_LOW, + VIRIDIS_MID, + viridisGradient, + viridisSamples, +} from '@/constants/viridis' + +// Class colors for the binned scales below. Sampled straight off the viridis +// ramp, lowest class first, so the legend gradient and the symbols agree. +const TDS_CLASS_COLORS = viridisSamples(6) +const DEPTH_CLASS_COLORS = viridisSamples(6) + export type OgcCollectionRecord = { id?: string collection_id?: string @@ -15,21 +28,19 @@ export type ResolvedCollection = { } export const TDS_LEGEND = { - gradient: - 'linear-gradient(90deg, #2b83ba 0%, #4daf4a 20%, #a6d96a 40%, #fee08b 60%, #f46d43 80%, #d73027 100%)', + gradient: viridisGradient(), minLabel: '<300', maxLabel: '5000+ mg/L', } export const DEPTH_LEGEND = { - gradient: - 'linear-gradient(90deg, #1a9850 0%, #66bd63 25%, #a6d96a 50%, #fee08b 70%, #f46d43 85%, #d73027 100%)', + gradient: viridisGradient(), minLabel: 'Shallow', maxLabel: 'Deep', } export const TREND_LEGEND = { - gradient: 'linear-gradient(90deg, #2c7bb6 0%, #bdbdbd 50%, #d73027 100%)', + gradient: viridisGradient(3), minLabel: 'Declining', maxLabel: 'Rising', } @@ -103,12 +114,12 @@ export const latestTdsColorFromFeature = (feature: any): string | undefined => { ] ) if (value === undefined) return undefined - if (value < 300) return '#2b83ba' - if (value < 500) return '#4daf4a' - if (value < 1000) return '#a6d96a' - if (value < 2000) return '#fee08b' - if (value < 5000) return '#f46d43' - return '#d73027' + if (value < 300) return TDS_CLASS_COLORS[0] + if (value < 500) return TDS_CLASS_COLORS[1] + if (value < 1000) return TDS_CLASS_COLORS[2] + if (value < 2000) return TDS_CLASS_COLORS[3] + if (value < 5000) return TDS_CLASS_COLORS[4] + return TDS_CLASS_COLORS[5] } export const averageTdsColorFromFeature = (feature: any): string | undefined => { @@ -122,12 +133,12 @@ export const averageTdsColorFromFeature = (feature: any): string | undefined => [/count/i, /num/i, /code/i, /id$/i, /unit/i, /rank/i, /class/i, /flag/i, /latest/i] ) if (value === undefined) return undefined - if (value < 300) return '#2b83ba' - if (value < 500) return '#4daf4a' - if (value < 1000) return '#a6d96a' - if (value < 2000) return '#fee08b' - if (value < 5000) return '#f46d43' - return '#d73027' + if (value < 300) return TDS_CLASS_COLORS[0] + if (value < 500) return TDS_CLASS_COLORS[1] + if (value < 1000) return TDS_CLASS_COLORS[2] + if (value < 2000) return TDS_CLASS_COLORS[3] + if (value < 5000) return TDS_CLASS_COLORS[4] + return TDS_CLASS_COLORS[5] } export const latestDepthToWaterColorFromFeature = ( @@ -160,20 +171,20 @@ export const latestDepthToWaterColorFromFeature = ( ] ) if (value === undefined) return undefined - if (value < 25) return '#1a9850' - if (value < 75) return '#66bd63' - if (value < 150) return '#a6d96a' - if (value < 250) return '#fee08b' - if (value < 400) return '#f46d43' - return '#d73027' + if (value < 25) return DEPTH_CLASS_COLORS[0] + if (value < 75) return DEPTH_CLASS_COLORS[1] + if (value < 150) return DEPTH_CLASS_COLORS[2] + if (value < 250) return DEPTH_CLASS_COLORS[3] + if (value < 400) return DEPTH_CLASS_COLORS[4] + return DEPTH_CLASS_COLORS[5] } export const trendColorFromFeature = (feature: any): string | undefined => { const label = findStringProperty(feature, [/trend/i, /trend_class/i])?.toLowerCase() if (label) { - if (/(declin|decreas|fall|down)/.test(label)) return '#2c7bb6' - if (/(stable|flat|no change|neutral)/.test(label)) return '#bdbdbd' - if (/(ris|increas|up)/.test(label)) return '#d73027' + if (/(declin|decreas|fall|down)/.test(label)) return VIRIDIS_LOW + if (/(stable|flat|no change|neutral)/.test(label)) return VIRIDIS_MID + if (/(ris|increas|up)/.test(label)) return VIRIDIS_HIGH } const slope = findNumericPropertyWithPriority( @@ -183,9 +194,9 @@ export const trendColorFromFeature = (feature: any): string | undefined => { [/count/i, /num/i, /code/i, /id$/i, /unit/i, /rank/i, /class/i, /flag/i] ) if (slope === undefined) return undefined - if (slope < -0.2) return '#2c7bb6' - if (slope > 0.2) return '#d73027' - return '#bdbdbd' + if (slope < -0.2) return VIRIDIS_LOW + if (slope > 0.2) return VIRIDIS_HIGH + return VIRIDIS_MID } const normalize = (value?: string): string => From 4a39b89922237549e06e16089c7bb75aae716f24 Mon Sep 17 00:00:00 2001 From: Jake Ross Date: Thu, 20 Aug 2026 21:54:15 -0700 Subject: [PATCH 2/3] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/constants/viridis.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/constants/viridis.ts b/src/constants/viridis.ts index 3918aa93..811e1463 100644 --- a/src/constants/viridis.ts +++ b/src/constants/viridis.ts @@ -30,6 +30,7 @@ export const VIRIDIS_ANCHORS = [ ] as const const clamp01 = (value: number): number => { + // Treat non-finite values (NaN/±Infinity) as 0 so callers don't have to guard. if (!Number.isFinite(value)) return 0 if (value < 0) return 0 if (value > 1) return 1 @@ -47,8 +48,9 @@ const channelToHex = (value: number): string => /** * Color at `position` along the viridis ramp, where 0 is the dark purple end - * and 1 is the bright yellow end. Values outside 0..1 (and non-finite values) - * are clamped so callers never have to sanitize computed ratios. + * and 1 is the bright yellow end. Values outside 0..1 are clamped, and + * non-finite values (NaN/±Infinity) are treated as 0 so callers never have to + * sanitize computed ratios. */ export const viridisColor = (position: number): string => { const scaled = clamp01(position) * (VIRIDIS_ANCHORS.length - 1) From 35f3a6fab4e87064bac39d3245ed0d7779e6c642 Mon Sep 17 00:00:00 2001 From: jakeross Date: Thu, 20 Aug 2026 21:55:37 -0700 Subject: [PATCH 3/3] fix(map): clamp infinite ramp positions to the nearer end viridisColor rejected every non-finite position to the dark end, so +Infinity returned the low color instead of the high one. The infinities are ordered, so they now clamp like any other out-of-range value; only NaN, which has no position on the ramp, falls to the low end. --- src/constants/viridis.ts | 11 ++++++----- src/test/utils/viridis.test.ts | 8 ++++++-- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/constants/viridis.ts b/src/constants/viridis.ts index 811e1463..4bdaed88 100644 --- a/src/constants/viridis.ts +++ b/src/constants/viridis.ts @@ -30,8 +30,9 @@ export const VIRIDIS_ANCHORS = [ ] as const const clamp01 = (value: number): number => { - // Treat non-finite values (NaN/±Infinity) as 0 so callers don't have to guard. - if (!Number.isFinite(value)) return 0 + // NaN has no position on the ramp, so it falls to the low end. The + // infinities are ordered, and clamp like any other out-of-range value. + if (Number.isNaN(value)) return 0 if (value < 0) return 0 if (value > 1) return 1 return value @@ -48,9 +49,9 @@ const channelToHex = (value: number): string => /** * Color at `position` along the viridis ramp, where 0 is the dark purple end - * and 1 is the bright yellow end. Values outside 0..1 are clamped, and - * non-finite values (NaN/±Infinity) are treated as 0 so callers never have to - * sanitize computed ratios. + * and 1 is the bright yellow end. Values outside 0..1 are clamped to the + * nearer end — including the infinities — and NaN maps to the dark end, so + * callers never have to sanitize a computed ratio. */ export const viridisColor = (position: number): string => { const scaled = clamp01(position) * (VIRIDIS_ANCHORS.length - 1) diff --git a/src/test/utils/viridis.test.ts b/src/test/utils/viridis.test.ts index bf35735d..04634c89 100644 --- a/src/test/utils/viridis.test.ts +++ b/src/test/utils/viridis.test.ts @@ -60,11 +60,15 @@ describe('viridisColor', () => { expect(viridisColor(0.5 / (VIRIDIS_ANCHORS.length - 1))).toBe('#461566') }) - it('clamps out-of-range and non-finite positions', () => { + it('clamps out-of-range positions to the nearer end', () => { expect(viridisColor(-3)).toBe(VIRIDIS_LOW) expect(viridisColor(42)).toBe(VIRIDIS_HIGH) + expect(viridisColor(Number.NEGATIVE_INFINITY)).toBe(VIRIDIS_LOW) + expect(viridisColor(Number.POSITIVE_INFINITY)).toBe(VIRIDIS_HIGH) + }) + + it('maps NaN to the dark end rather than throwing', () => { expect(viridisColor(Number.NaN)).toBe(VIRIDIS_LOW) - expect(viridisColor(Number.POSITIVE_INFINITY)).toBe(VIRIDIS_LOW) }) it('always produces a six-digit lowercase hex color', () => {