Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/components/ContactShow/AssociatedSitesMapCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
}}
/>
Expand Down
16 changes: 11 additions & 5 deletions src/components/card/InteractiveSatelliteMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
}}
/>
Expand All @@ -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<any>(null)
Expand Down Expand Up @@ -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,
}}
/>
Expand Down
91 changes: 91 additions & 0 deletions src/constants/mapColors.ts
Original file line number Diff line number Diff line change
@@ -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<MapLayerColorKey, string>

/**
* 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
105 changes: 105 additions & 0 deletions src/constants/viridis.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// ---------------------------------------------------------------------------
// 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 => {
// 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
}

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 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.
*/
Comment thread
Copilot marked this conversation as resolved.
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]
3 changes: 2 additions & 1 deletion src/hooks/useLayer.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useOne } from '@refinedev/core'
import { MAP_SYMBOL_STROKE_COLOR } from '@/constants/mapColors'

export const useLayer = ({
thing_type,
Expand Down Expand Up @@ -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,
},
},
Expand Down
8 changes: 6 additions & 2 deletions src/hooks/useOGCLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -233,7 +237,7 @@ export const useOGCLayer = ({
collection,
label,
providerName = 'ogcapi',
color = '#9cd0ab',
color = MAP_DEFAULT_LAYER_COLOR,
colorAccessor,
textAccessor,
textColor = '#111111',
Expand Down Expand Up @@ -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: {
Expand Down
Loading
Loading