diff --git a/lib/classes/geojson-layer.js b/lib/classes/geojson-layer.js index 1dd41f7..df02389 100644 --- a/lib/classes/geojson-layer.js +++ b/lib/classes/geojson-layer.js @@ -3,6 +3,7 @@ import EsriJSON from 'ol/format/EsriJSON.js'; import GeoJSON from "ol/format/GeoJSON"; import VectorSource from "ol/source/Vector"; import VectorLayer from "ol/layer/Vector"; +import Overlay from "ol/Overlay.js"; import { createXYZ } from 'ol/tilegrid.js'; import { tile as tileStrategy } from 'ol/loadingstrategy.js'; @@ -19,11 +20,15 @@ class GeoJSONLayer extends Layer { map = null, type = 'geojson', visible = true, + fit = false, + tooltip, onClick }) { super({ id, name, source, map, visible }); + this.fit = fit this.style = style this.type = type + this.tooltip = tooltip this.onClick = onClick this._add(); } @@ -36,7 +41,7 @@ class GeoJSONLayer extends Layer { // establishing OL vector layer format if (this.type === 'geojson') { // if url, load remote data - if (typeof (this.source) === 'string' && this.source.includes('http')) { + if (typeof (this.source) === 'string') { this.layer = new VectorLayer({ source: new VectorSource({ url: this.source, @@ -108,11 +113,117 @@ class GeoJSONLayer extends Layer { } if (typeof this.onClick === 'function') { - this.map.on('click', (e) => { + this._listen('click', (e) => { this.layer.getFeatures(e.pixel).then((features) => { const feature = features.length ? features[0] : undefined; if (!feature) return; - this.onClick(feature, e.coordinate)({ store }); + const result = this.onClick(feature, e.coordinate); + if (typeof result === "function") result({ store }); + }); + }); + } + + if (typeof this.tooltip === "function") { + const tooltipId = `tooltip-${this.id}`; + this.map.getOverlays().getArray() + .filter((overlay) => overlay.get("id") === tooltipId) + .forEach((overlay) => this.map.removeOverlay(overlay)); + + const tooltipElement = document.createElement("div"); + tooltipElement.style.background = "rgba(17, 24, 39, 0.92)"; + tooltipElement.style.borderRadius = "4px"; + tooltipElement.style.color = "#fff"; + tooltipElement.style.fontSize = "12px"; + tooltipElement.style.maxWidth = "18rem"; + tooltipElement.style.padding = "6px 8px"; + tooltipElement.style.pointerEvents = "none"; + tooltipElement.style.position = "relative"; + tooltipElement.style.whiteSpace = "pre-line"; + tooltipElement.style.zIndex = "10000"; + tooltipElement.style.display = "none"; + tooltipElement.dataset.groundworkGeoTooltip = "true"; + + const tooltipOverlay = new Overlay({ + element: tooltipElement, + offset: [0, -12], + positioning: "bottom-center", + }); + tooltipOverlay.set("id", tooltipId); + this._addOverlay(tooltipOverlay); + const raiseTooltipOverlay = () => { + const container = tooltipElement.parentElement; + if (container) { + container.style.pointerEvents = "none"; + container.style.zIndex = "10000"; + const overlayRoot = container.parentElement; + if (overlayRoot) { + overlayRoot.style.zIndex = "10000"; + } + } + }; + const hidePeerTooltips = () => { + this.map.getOverlays().getArray() + .filter((overlay) => overlay.get("id")?.startsWith("tooltip-")) + .forEach((overlay) => { + if (overlay !== tooltipOverlay) { + const element = overlay.getElement?.(); + if (element) element.style.display = "none"; + } + }); + }; + raiseTooltipOverlay(); + + this._listen("pointermove", (e) => { + this.layer.getFeatures(e.pixel).then((features) => { + const feature = features.length ? features[0] : undefined; + const text = feature ? this.tooltip(feature, e.coordinate) : ""; + if (!text) { + tooltipElement.style.display = "none"; + return; + } + const mapElement = this.map.getTargetElement?.(); + const mapWidth = mapElement?.clientWidth || 0; + const mapHeight = mapElement?.clientHeight || 0; + const nearLeft = e.pixel[0] < 160; + const nearRight = mapWidth && e.pixel[0] > mapWidth - 160; + const nearTop = e.pixel[1] < 80; + const nearBottom = mapHeight && e.pixel[1] > mapHeight - 80; + + if (mapWidth) { + tooltipElement.style.maxWidth = `${Math.max(160, Math.min(288, mapWidth - 16))}px`; + } + + if (nearTop && nearLeft) { + tooltipOverlay.setPositioning("top-left"); + tooltipOverlay.setOffset([8, 12]); + } else if (nearTop && nearRight) { + tooltipOverlay.setPositioning("top-right"); + tooltipOverlay.setOffset([-8, 12]); + } else if (nearBottom && nearLeft) { + tooltipOverlay.setPositioning("bottom-left"); + tooltipOverlay.setOffset([8, -12]); + } else if (nearBottom && nearRight) { + tooltipOverlay.setPositioning("bottom-right"); + tooltipOverlay.setOffset([-8, -12]); + } else if (nearTop) { + tooltipOverlay.setPositioning("top-center"); + tooltipOverlay.setOffset([0, 12]); + } else if (nearLeft) { + tooltipOverlay.setPositioning("bottom-left"); + tooltipOverlay.setOffset([8, -12]); + } else if (nearRight) { + tooltipOverlay.setPositioning("bottom-right"); + tooltipOverlay.setOffset([-8, -12]); + } else { + tooltipOverlay.setPositioning("bottom-center"); + tooltipOverlay.setOffset([0, -12]); + } + + hidePeerTooltips(); + tooltipElement.textContent = text; + tooltipElement.style.display = "block"; + tooltipOverlay.setPosition(e.coordinate); + raiseTooltipOverlay(); }); }); } @@ -121,7 +232,26 @@ class GeoJSONLayer extends Layer { // calling OL map add layer function this.map.addLayer(this.layer) - this._setAdded = true + if (this.fit) { + const fitToSource = () => { + const source = this.layer?.getSource?.(); + if (!source || !source.getFeatures().length) return; + this.map.getView().fit(source.getExtent(), { + padding: [32, 32, 32, 32], + maxZoom: 9, + duration: 250, + }); + }; + const source = this.layer.getSource(); + if (source.getState?.() === "ready" || source.getFeatures().length) { + fitToSource(); + } else { + source.once("featuresloadend", fitToSource); + source.once("change", fitToSource); + } + } + + this._setAdded() } } diff --git a/lib/classes/layer.js b/lib/classes/layer.js index a4e69e8..4442d5b 100644 --- a/lib/classes/layer.js +++ b/lib/classes/layer.js @@ -8,6 +8,8 @@ class Layer { this.added = false; this.layer = null; + this._mapListeners = []; + this._overlays = []; } show() { @@ -18,6 +20,32 @@ class Layer { this.layer.setVisible = false; } + _listen(type, listener) { + this.map.on(type, listener); + this._mapListeners.push({ listener, type }); + } + + _addOverlay(overlay) { + this.map.addOverlay(overlay); + this._overlays.push(overlay); + } + + _remove() { + if (!this.map) return; + + this._mapListeners.forEach(({ listener, type }) => { + this.map.un(type, listener); + }); + this._mapListeners = []; + + this._overlays.forEach((overlay) => this.map.removeOverlay(overlay)); + this._overlays = []; + + if (this.layer) this.map.removeLayer(this.layer); + this.layer = null; + this.added = false; + } + _setAdded() { this.added = true; } diff --git a/lib/classes/status-marker-layer.js b/lib/classes/status-marker-layer.js new file mode 100644 index 0000000..1e1cb7c --- /dev/null +++ b/lib/classes/status-marker-layer.js @@ -0,0 +1,154 @@ +import Feature from "ol/Feature"; +import Point from "ol/geom/Point"; +import VectorLayer from "ol/layer/Vector"; +import VectorSource from "ol/source/Vector"; +import { fromLonLat } from "ol/proj"; + +import Layer from "./layer"; +import { + createStatusMarkerStyle, + getThresholdStatus, + statusColor, +} from "../styles/status-marker-style"; + +function defaultCoordinateAccessor(item) { + const lon = item.longitude ?? item.lon ?? item.x; + const lat = item.latitude ?? item.lat ?? item.y; + + return [Number(lon), Number(lat)]; +} + +function defaultShapeAccessor(item) { + const type = String(item.type ?? item.locationType ?? item.kind ?? "").toLowerCase(); + + if (type.includes("reservoir") || type.includes("project")) return "triangle"; + if (type.includes("lock")) return "square"; + return "diamond"; +} + +function defaultLabelAccessor(item) { + return item.mapLabel ?? item.publicName ?? item.name ?? item.id; +} + +function normalizeFeatures(data, coordinateAccessor) { + const entries = data?.type === "FeatureCollection" ? data.features : data; + + return (entries || []) + .map((entry) => { + const properties = entry.type === "Feature" ? entry.properties || {} : entry; + const coordinates = + entry.type === "Feature" + ? entry.geometry?.coordinates + : coordinateAccessor(properties); + + if (!coordinates || coordinates.length < 2) return null; + + const lon = Number(coordinates[0]); + const lat = Number(coordinates[1]); + + if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null; + + const feature = new Feature({ + geometry: new Point(fromLonLat([lon, lat])), + ...properties, + }); + + return feature; + }) + .filter(Boolean); +} + +class StatusMarkerLayer extends Layer { + constructor({ + id, + name, + source = [], + map = null, + visible = true, + fit = false, + thresholds, + colors, + radius = 9, + coordinateAccessor = defaultCoordinateAccessor, + shapeAccessor = defaultShapeAccessor, + labelAccessor = defaultLabelAccessor, + statusAccessor, + style, + onClick, + }) { + super({ id, name, source, map, visible }); + this.colors = colors; + this.coordinateAccessor = coordinateAccessor; + this.fit = fit; + this.labelAccessor = labelAccessor; + this.onClick = onClick; + this.radius = radius; + this.shapeAccessor = shapeAccessor; + this.statusAccessor = statusAccessor; + this.style = style; + this.thresholds = thresholds; + this._add(); + } + + _getStyle(feature) { + if (typeof this.style === "function") return this.style(feature); + if (this.style) return this.style; + + const properties = feature.getProperties(); + let status = + typeof this.statusAccessor === "function" + ? this.statusAccessor(properties) + : undefined; + + if (status === null || status === undefined) { + status = getThresholdStatus(properties.statusValue ?? properties.value, this.thresholds); + } + + return createStatusMarkerStyle({ + color: statusColor(status, this.colors), + label: this.labelAccessor ? this.labelAccessor(properties) : "", + radius: this.radius, + shape: this.shapeAccessor ? this.shapeAccessor(properties) : "circle", + }); + } + + _add() { + if (!this.map || this.added) return; + + const source = new VectorSource({ + features: normalizeFeatures(this.source, this.coordinateAccessor), + }); + + this.layer = new VectorLayer({ + source, + style: (feature) => this._getStyle(feature), + zIndex: 1000, + visible: this.visible, + }); + this.layer.show = this.visible; + + if (typeof this.onClick === "function") { + this._listen("click", (event) => { + this.layer.getFeatures(event.pixel).then((features) => { + const feature = features[0]; + if (feature) this.onClick(feature, event.coordinate); + }); + }); + } + + this.map.addLayer(this.layer); + + if (this.fit && source.getFeatures().length) { + this.map.getView().fit(source.getExtent(), { + padding: [32, 32, 32, 32], + maxZoom: 11, + duration: 250, + }); + } + + this._setAdded(); + } +} + +export default StatusMarkerLayer; +export { StatusMarkerLayer }; diff --git a/lib/classes/tile-layer.js b/lib/classes/tile-layer.js index efc9ab1..84243cd 100644 --- a/lib/classes/tile-layer.js +++ b/lib/classes/tile-layer.js @@ -9,19 +9,26 @@ class TileLayer extends Layer { source, style, map = null, - visible = true + visible = true, + opacity = 1, + zIndex }) { super({ id, name, source, map, visible }); this.style = style + this.opacity = opacity + this.zIndex = zIndex this._add(); } _add() { if (!this.map) return; this.layer = new Tile({ - source: new ImageTile({ + source: typeof this.source === "string" ? new ImageTile({ url: this.source - }), + }) : this.source, + opacity: this.opacity, + visible: this.visible, + zIndex: this.zIndex, }); // calling OL map add layer function this.map.addLayer(this.layer) @@ -31,4 +38,4 @@ class TileLayer extends Layer { } export default TileLayer; -export { TileLayer } \ No newline at end of file +export { TileLayer } diff --git a/lib/components/map-layout.jsx b/lib/components/map-layout.jsx index 92dcbe6..ff38c4c 100644 --- a/lib/components/map-layout.jsx +++ b/lib/components/map-layout.jsx @@ -14,7 +14,7 @@ const MapLayout = ({ return ( - + {leftSidebar} diff --git a/lib/components/map-side-control.jsx b/lib/components/map-side-control.jsx new file mode 100644 index 0000000..1db16b8 --- /dev/null +++ b/lib/components/map-side-control.jsx @@ -0,0 +1,126 @@ +import PropTypes from "prop-types"; + +function MapSideControl({ + buttonLabel = "Layers", + children, + isOpen, + onToggle, + side = "right", + title = "Map controls", + top = 12, + zIndex = 120, +}) { + const containerStyle = { + alignItems: "flex-start", + display: "flex", + gap: "0.5rem", + height: `calc(100% - ${top + 12}px)`, + position: "absolute", + top, + zIndex, + ...(side === "left" ? { left: 12 } : { right: 12 }), + }; + const buttonStyle = { + background: "#fff", + border: "1px solid #d1d5db", + borderRadius: 4, + boxShadow: "0 1px 4px rgba(15, 23, 42, 0.18)", + color: "#1f2937", + fontSize: 14, + fontWeight: 700, + lineHeight: 1.2, + padding: "0.55rem 0.75rem", + }; + const panelStyle = { + background: "#fff", + border: "1px solid #d1d5db", + borderRadius: 4, + boxShadow: "0 12px 28px rgba(15, 23, 42, 0.22)", + display: "flex", + flexDirection: "column", + maxHeight: "100%", + overflow: "hidden", + width: "min(20rem, calc(100vw - 2rem))", + }; + const headerStyle = { + alignItems: "center", + borderBottom: "1px solid #e5e7eb", + display: "flex", + gap: "0.5rem", + justifyContent: "space-between", + padding: "0.5rem 0.75rem", + }; + const bodyStyle = { + flex: "1 1 auto", + minHeight: 0, + overflowY: "auto", + overscrollBehavior: "contain", + padding: "0.75rem", + }; + + return ( +
+ {side === "right" && ( + + )} + {isOpen && ( +
+
+

+ {title} +

+ +
+
+ {children} +
+
+ )} + {side === "left" && ( + + )} +
+ ); +} + +MapSideControl.propTypes = { + buttonLabel: PropTypes.node, + children: PropTypes.node, + isOpen: PropTypes.bool, + onToggle: PropTypes.func, + side: PropTypes.oneOf(["left", "right"]), + title: PropTypes.node, + top: PropTypes.number, + zIndex: PropTypes.number, +}; + +export default MapSideControl; +export { MapSideControl }; diff --git a/lib/components/map.jsx b/lib/components/map.jsx index 788cb7c..6b1de6a 100644 --- a/lib/components/map.jsx +++ b/lib/components/map.jsx @@ -5,7 +5,7 @@ import 'ol/ol.css'; // import LayerToggle from './map-controls/layer-switcher'; -function Map({ mapId, layers, viewConfig }) { +function Map({ mapId, layers, viewConfig, controls }) { const mapEl = useRef(); const { doMapsInitialize, @@ -23,7 +23,8 @@ function Map({ mapId, layers, viewConfig }) { doMapsInitialize({ id: mapId, target: mapEl.current, - viewConfig: viewConfig + viewConfig: viewConfig, + controls: controls }); doBasemapsInitialize(mapId) }, [mapEl.current]); diff --git a/lib/components/status-legend.jsx b/lib/components/status-legend.jsx new file mode 100644 index 0000000..14630df --- /dev/null +++ b/lib/components/status-legend.jsx @@ -0,0 +1,50 @@ +import { DEFAULT_STATUS_COLORS } from "../styles/status-marker-style"; + +const DEFAULT_STATUS_LEGEND_ITEMS = [ + { color: DEFAULT_STATUS_COLORS[7], label: "150%+" }, + { color: DEFAULT_STATUS_COLORS[6], label: "125%" }, + { color: DEFAULT_STATUS_COLORS[5], label: "100%" }, + { color: DEFAULT_STATUS_COLORS[4], label: "75%" }, + { color: DEFAULT_STATUS_COLORS[3], label: "50%" }, + { color: DEFAULT_STATUS_COLORS[2], label: "25%" }, + { color: DEFAULT_STATUS_COLORS[1], label: "10%" }, + { color: DEFAULT_STATUS_COLORS[0], label: "0%+" }, + { color: "rgba(255,255,255,0)", label: "No regulating value" }, + { color: "#d1d5db", label: "No data" }, +]; + +function StatusLegend({ title = "Status", items = DEFAULT_STATUS_LEGEND_ITEMS }) { + return ( +
+
{title}
+
+ {items.map((item) => ( +
+
+ ))} +
+
+ ); +} + +export { DEFAULT_STATUS_LEGEND_ITEMS, StatusLegend }; +export default StatusLegend; diff --git a/lib/index.jsx b/lib/index.jsx index 4943217..5649915 100644 --- a/lib/index.jsx +++ b/lib/index.jsx @@ -1,11 +1,24 @@ // mapping components export { Map } from "./components/map"; export { MapLayout } from "./components/map-layout"; +export { MapSideControl } from "./components/map-side-control.jsx"; +export { DEFAULT_STATUS_LEGEND_ITEMS, StatusLegend } from "./components/status-legend.jsx"; export { Layer } from "./classes/layer.js"; export { GeoJSONLayer } from "./classes/geojson-layer.js"; +export { StatusMarkerLayer } from "./classes/status-marker-layer.js"; export { ArcGISTileLayer } from "./classes/arcgis-tile-layer.js"; export { TileLayer } from "./classes/tile-layer.js"; +export { default as Fill } from "ol/style/Fill.js"; +export { default as Stroke } from "ol/style/Stroke.js"; +export { default as Style } from "ol/style/Style.js"; +export { + DEFAULT_STATUS_COLORS, + createStatusMarkerStyle, + getThresholdStatus, + statusColor, +} from "./styles/status-marker-style.js"; export { useGroundworkGeo } from "./hooks/useGroundworkGeo.js"; +export { groundworkGeoBundles } from "./store/index.js"; // export { LayerTree } from "./components/mapping/tools/legend"; export { ActivityItem as ToolbarButton } from "./components/toolbar/activity-bar"; export { Toolbar } from "./components/toolbar/toolbar"; diff --git a/lib/store/index.js b/lib/store/index.js index 6a0cbce..9aa9a4f 100644 --- a/lib/store/index.js +++ b/lib/store/index.js @@ -6,9 +6,14 @@ import basemapsBundle from "./basemaps-bundle"; // import createGeojsonLayerBundle from "./create-geojson-layer-bundle"; // import divisionsLayerBundle from "./divisions-layer-bundle"; -export default composeBundles( +const groundworkGeoBundles = [ createCacheBundle({ cacheFn: cache.set }), mapsBundle, layersBundle, basemapsBundle -) \ No newline at end of file +]; + +const groundworkGeoStore = composeBundles(...groundworkGeoBundles) + +export default groundworkGeoStore; +export { groundworkGeoBundles }; diff --git a/lib/store/layers-bundle.js b/lib/store/layers-bundle.js index 11f73c2..a057d77 100644 --- a/lib/store/layers-bundle.js +++ b/lib/store/layers-bundle.js @@ -4,6 +4,7 @@ const layersBundle = { getReducer: () => { const initialData = { layers: [], + layersByMapId: {}, }; return (state = initialData, { type, payload }) => { @@ -20,11 +21,19 @@ const layersBundle = { }, selectLayers: (state) => state.layers.layers, + selectLayersByMapId: (state) => state.layers.layersByMapId, doLayersInitialize: (layers, mapId) => ({ dispatch, store }) => { const map = store.selectMaps()[mapId]; + const layersByMapId = store.selectLayersByMapId?.() || {}; + const previousLayers = layersByMapId[mapId] || []; + + previousLayers.forEach((previousLayer) => { + previousLayer?._remove?.(); + }); + function addLayer(layer) { // adding map to our layer property layer.map = map; @@ -39,9 +48,13 @@ const layersBundle = { type: "LAYERS_INITIALIZED", payload: { layers, + layersByMapId: { + ...layersByMapId, + [mapId]: layers, + }, }, }); } }; -export default layersBundle; \ No newline at end of file +export default layersBundle; diff --git a/lib/store/layers-bundle.test.js b/lib/store/layers-bundle.test.js new file mode 100644 index 0000000..5cc54a5 --- /dev/null +++ b/lib/store/layers-bundle.test.js @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from "vitest"; + +import layersBundle from "./layers-bundle"; + +describe("layers bundle", () => { + it("removes prior layer resources before initializing replacements", () => { + const previous = { _remove: vi.fn() }; + const next = { _add: vi.fn(), id: "status" }; + const map = {}; + const dispatch = vi.fn(); + const store = { + selectLayersByMapId: () => ({ map: [previous] }), + selectMaps: () => ({ map }), + }; + + layersBundle.doLayersInitialize([next], "map")({ dispatch, store }); + + expect(previous._remove).toHaveBeenCalledOnce(); + expect(next.map).toBe(map); + expect(next._add).toHaveBeenCalledOnce(); + expect(dispatch).toHaveBeenCalledWith({ + type: "LAYERS_INITIALIZED", + payload: { + layers: [next], + layersByMapId: { map: [next] }, + }, + }); + }); +}); diff --git a/lib/store/maps-bundle.js b/lib/store/maps-bundle.js index a76b928..70151a5 100644 --- a/lib/store/maps-bundle.js +++ b/lib/store/maps-bundle.js @@ -3,6 +3,7 @@ import View from 'ol/View'; import TileLayer from 'ol/layer/Tile'; import { XYZ } from 'ol/source'; import { fromLonLat } from 'ol/proj'; +import { FullScreen } from 'ol/control.js'; const actions = { INITIALIZED: 'MAPS_INITIALIZED', @@ -68,7 +69,7 @@ const mapsBundle = { }, doMapsInitialize: - ({ id, target, basemapIdx = 0, viewConfig }) => + ({ id, target, basemapIdx = 0, viewConfig, controls = {} }) => ({ dispatch, store }) => { const existingMaps = store.selectMaps(); if (existingMaps && existingMaps[id]) { @@ -90,10 +91,15 @@ const mapsBundle = { } + const mapControls = []; + if (controls.fullScreen) { + mapControls.push(new FullScreen()); + } + const map = new Map({ view: new View(config), target: target, - controls: [] + controls: mapControls }); // extend the map object to handle basemap switching, diff --git a/lib/styles/status-marker-style.js b/lib/styles/status-marker-style.js new file mode 100644 index 0000000..16ca58a --- /dev/null +++ b/lib/styles/status-marker-style.js @@ -0,0 +1,101 @@ +import { Circle, Fill, RegularShape, Stroke, Style, Text } from "ol/style"; + +const DEFAULT_STATUS_COLORS = [ + "#008000", + "#00c853", + "#5e9696", + "#b07c00", + "#fefe00", + "#e78b8b", + "#a60000", + "#f40000", +]; + +function getThresholdStatus(value, thresholds = [10, 25, 50, 75, 100, 125, 150]) { + if (value === null || value === undefined || Number.isNaN(Number(value))) { + return "missing"; + } + + const numericValue = Number(value); + const index = thresholds.findIndex((threshold) => numericValue < threshold); + + return index === -1 ? thresholds.length : index; +} + +function getMarkerShape(shape, radius, fill, stroke) { + if (shape === "triangle") { + return new RegularShape({ + points: 3, + radius, + rotation: 0, + fill, + stroke, + }); + } + + if (shape === "square") { + return new RegularShape({ + points: 4, + radius, + angle: Math.PI / 4, + fill, + stroke, + }); + } + + if (shape === "diamond") { + return new RegularShape({ + points: 4, + radius, + fill, + stroke, + }); + } + + return new Circle({ + radius, + fill, + stroke, + }); +} + +function createStatusMarkerStyle({ + color, + label, + radius = 9, + shape = "circle", + textColor = "#111827", + strokeColor = "#1f2937", + strokeWidth = 1.5, +} = {}) { + const resolvedColor = color || "#d1d5db"; + const fill = new Fill({ color: resolvedColor }); + const stroke = new Stroke({ color: strokeColor, width: strokeWidth }); + + return new Style({ + image: getMarkerShape(shape, radius, fill, stroke), + text: label + ? new Text({ + text: String(label), + offsetY: -20, + font: "600 12px system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif", + fill: new Fill({ color: textColor }), + stroke: new Stroke({ color: "rgba(255,255,255,0.85)", width: 3 }), + }) + : undefined, + }); +} + +function statusColor(status, colors = DEFAULT_STATUS_COLORS) { + if (status === "missing") return "#d1d5db"; + if (status === "transparent") return "rgba(255,255,255,0)"; + + return colors[status] || colors[colors.length - 1] || "#d1d5db"; +} + +export { + DEFAULT_STATUS_COLORS, + createStatusMarkerStyle, + getThresholdStatus, + statusColor, +}; diff --git a/lib/styles/status-marker-style.test.js b/lib/styles/status-marker-style.test.js new file mode 100644 index 0000000..7a75f25 --- /dev/null +++ b/lib/styles/status-marker-style.test.js @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_STATUS_COLORS, + getThresholdStatus, + statusColor, +} from "./status-marker-style"; + +describe("status marker thresholds", () => { + it("places values into the expected status buckets", () => { + expect(getThresholdStatus(null)).toBe("missing"); + expect(getThresholdStatus("not-a-number")).toBe("missing"); + expect(getThresholdStatus(9.9)).toBe(0); + expect(getThresholdStatus(10)).toBe(1); + expect(getThresholdStatus(150)).toBe(7); + }); + + it("resolves special and numeric status colors", () => { + expect(statusColor("missing")).toBe("#d1d5db"); + expect(statusColor("transparent")).toBe("rgba(255,255,255,0)"); + expect(statusColor(2)).toBe(DEFAULT_STATUS_COLORS[2]); + }); +}); diff --git a/package-lock.json b/package-lock.json index a3ab61c..4d1d0ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,14 +21,14 @@ "@eslint/js": "^9.13.0", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", - "@vitejs/plugin-react": "^5.2.0", + "@vitejs/plugin-react": "^4.7.0", "eslint": "^9.13.0", "eslint-plugin-react": "^7.37.2", "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.14", "globals": "^15.11.0", - "react": "^19.0.0", - "react-dom": "^19.0.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", "vite": "^7.2.7", "vitest": "^4.1.8" }, @@ -1050,9 +1050,9 @@ "integrity": "sha512-rYUZ+VFjPHD0NT2JYKj64NxXxrV642IiyaUxxorTEj0S3hT7B5Ixezyc9Fn+XvSk0ETEBp5VWjGIErzh0ug0Xw==" }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.3", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", - "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", "dev": true, "license": "MIT" }, @@ -1148,9 +1148,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1165,9 +1162,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1182,9 +1176,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1199,9 +1190,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1216,9 +1204,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1233,9 +1218,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1250,9 +1232,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1267,9 +1246,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1284,9 +1260,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1301,9 +1274,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1318,9 +1288,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1335,9 +1302,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1352,9 +1316,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1556,24 +1517,24 @@ } }, "node_modules/@vitejs/plugin-react": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", - "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.29.0", + "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-rc.3", + "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", - "react-refresh": "^0.18.0" + "react-refresh": "^0.17.0" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^14.18.0 || >=16.0.0" }, "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "node_modules/@vitest/expect": { @@ -3577,8 +3538,7 @@ "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "node_modules/js-yaml": { "version": "4.2.0", @@ -3715,7 +3675,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -4175,25 +4134,29 @@ } }, "node_modules/react": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", - "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", - "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "devOptional": true, "license": "MIT", "dependencies": { - "scheduler": "^0.27.0" + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" }, "peerDependencies": { - "react": "^19.2.4" + "react": "^18.3.1" } }, "node_modules/react-icons": { @@ -4211,9 +4174,9 @@ "dev": true }, "node_modules/react-refresh": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", - "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", "dev": true, "license": "MIT", "engines": { @@ -4405,11 +4368,14 @@ } }, "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "devOptional": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } }, "node_modules/semver": { "version": "6.3.1", diff --git a/package.json b/package.json index 7f37743..c952d17 100644 --- a/package.json +++ b/package.json @@ -45,14 +45,14 @@ "@eslint/js": "^9.13.0", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", - "@vitejs/plugin-react": "^5.2.0", + "@vitejs/plugin-react": "^4.7.0", "eslint": "^9.13.0", "eslint-plugin-react": "^7.37.2", "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.14", "globals": "^15.11.0", - "react": "^19.0.0", - "react-dom": "^19.0.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", "vite": "^7.2.7", "vitest": "^4.1.8" } diff --git a/vite.config.js b/vite.config.js index f4ca0d6..7511306 100644 --- a/vite.config.js +++ b/vite.config.js @@ -20,11 +20,12 @@ export default defineConfig(({ mode }) => { entry: "lib/index.jsx", }, rollupOptions: { - external: ["react", "react-dom"], + external: ["react", "react-dom", "react/jsx-runtime"], output: { globals: { react: "React", "react-dom": "ReactDOM", + "react/jsx-runtime": "jsxRuntime", }, }, },