import { Bounds, Feature, FeatureCollection, LineString, MultiLineString, Point } from "geom"; import { AreaParam, Collection, District, House, HouseParameter, StatisticalArea, TrainStation, TrainTracks, } from "models"; import { Svg, SvgOptions } from "svg"; /** * Math utility functions */ export class MapMath { /** * Clamp a value between min and max * @param {number} value * @param {number} min * @param {number} max * @returns {number} */ static clamp(value, min, max) { return Math.max(min, Math.min(value, max)); } /** * Calculate linear interpolation between two values * @param {number} a * @param {number} b * @param {number} t * @returns {number} */ static lerp(a, b, t) { return a + (b - a) * t; } /** * Normalize a value from one range to another * @param {number} value * @param {number} min * @param {number} max * @returns {number} */ static normalize(value, min, max) { return Math.max(0, Math.min(1, (value - min) / (max - min))); } } /** * Panning and inertia configuration */ export class PanningConfig { static DEFAULT_FRICTION = 0.995; static DEFAULT_SPEED_THRESHOLD = 0.001; static DEFAULT_BOUNCE_FACTOR = 0.5; static DEFAULT_VIEWBOX_SCALE = 1; } export class MapEl { /** @type {SVGSVGElement} */ svg; /** @type {Collection|null} */ #collection = null; /** @type {SVGGElement|null} */ #housesGroup = null; /** @type {SVGGElement|null} */ #statAreasGroup = null; /** @type {Function|null} */ #onHouseClick; /** @type {Function} */ #onHouseHover; /** @type {number|undefined} */ #modalTimer; /** @type {boolean} */ #persistentModal = false; /** @type {Bounds|null} */ #fullBounds = null; /** @type {Point|null} */ #centerPoint = null; /** @type {number} */ #viewHeightMeters = 10000; // Initial view height in meters /** @type {string} */ /** * @param {Object} options * @param {Function} options.onHouseClick * @param {Function} options.onHouseHover */ constructor(options) { const svg = Svg.svg( new SvgOptions({ attributes: { preserveAspectRatio: "xMidYMid meet", viewBox: "24 60 2 2", // longitude 24-26, latitude 60-62 }, styles: { cursor: "grab", display: "block", flex: "1", minHeight: "0", touchAction: "none", // Important for pinch zoom }, }), ); this.svg = svg; this.#onHouseClick = options.onHouseClick; this.#onHouseHover = options.onHouseHover; this.#enableControls(this.svg); } /** * @param {Bounds} bounds */ #setInitialViewBox(bounds) { const avgLat = (bounds.minY + bounds.maxY) / 2; const cosFactor = Math.cos((avgLat * Math.PI) / 180); const width = (bounds.maxX - bounds.minX) * cosFactor; const height = bounds.maxY - bounds.minY; // Calculate initial center point and view height this.#centerPoint = new Point(bounds.minX + width / 2, bounds.minY + height / 2); this.#viewHeightMeters = this.#calculateViewHeightMeters(height); this.svg.setAttribute("viewBox", `${bounds.minX} ${-bounds.maxY} ${width} ${height}`); } /** * Calculate view height in meters based on latitude and view height in degrees * @param {number} heightDegrees * @returns {number} */ #calculateViewHeightMeters(heightDegrees) { // Approximate conversion: 1 degree latitude ≈ 111,000 meters // 1 degree longitude varies by latitude: 111,000 * cos(latitude) return heightDegrees * 111000; } /** * Calculate view height in degrees based on meters and latitude * @param {number} meters * @returns {number} */ #calculateViewHeightDegrees(meters) { return meters / 111000; } /** * Zoom the map to a specific scale and center point * @param {number} scaleFactor * @param {Point|null} zoomCenter */ #zoom(scaleFactor, zoomCenter = null) { const vb = this.svg.viewBox.baseVal; const currentCenter = new Point(vb.x + vb.width / 2, vb.y + vb.height / 2); // Calculate new view height in meters this.#viewHeightMeters *= scaleFactor; // Clamp view height to reasonable limits (100m to 1000km) this.#viewHeightMeters = MapMath.clamp(this.#viewHeightMeters, 100, 1000000); // Calculate new view height in degrees const newHeightDegrees = this.#calculateViewHeightDegrees(this.#viewHeightMeters); // Calculate new width based on aspect ratio const aspectRatio = vb.width / vb.height; const newWidthDegrees = newHeightDegrees * aspectRatio; // Determine zoom center point let zoomPoint = currentCenter; if (zoomCenter) { zoomPoint = zoomCenter; } else if (this.#centerPoint) { zoomPoint = new Point(this.#centerPoint.lng, -this.#centerPoint.lat); } // Calculate new viewBox const newX = zoomPoint.lng - newWidthDegrees / 2; const newY = zoomPoint.lat - newHeightDegrees / 2; // Update center point this.#centerPoint = new Point(newX + newWidthDegrees / 2, -(newY + newHeightDegrees / 2)); // Apply new viewBox this.svg.setAttribute("viewBox", `${newX} ${newY} ${newWidthDegrees} ${newHeightDegrees}`); // Clamp to bounds MapEl.#clampViewBox(this.svg, this.#fullBounds); } /** * Initialize map with empty content * @param {Collection} collection * @param {HouseParameter} houseParameter * @param {AreaParam} areaParameter * @returns {SVGSVGElement} */ initialize(collection, houseParameter, areaParameter) { this.#collection = collection; this.#setInitialViewBox(District.bounds(collection.districts)); const transformGroup = Svg.g( new SvgOptions({ attributes: { transform: "scale(1, -1)" }, children: [ Svg.g( new SvgOptions({ attributes: { "pointer-events": "none", }, children: [ ...MapEl.#getStatisticalAreas(collection.statisticalAreas, areaParameter), ...MapEl.#getStatisticalAreaLabels(collection.statisticalAreas), ], id: "statistical-areas", }), ), Svg.g( new SvgOptions({ attributes: { "pointer-events": "none", "stroke-width": "0.0005", }, children: [ ...MapEl.#getCoastLine(collection.coastLine), ...MapEl.#getRoads(collection.mainRoads), ], id: "background", }), ), Svg.g( new SvgOptions({ attributes: { fill: "none", "pointer-events": "none", stroke: "rgba(255, 68, 68, 1)", "stroke-width": "0.001", }, children: MapEl.#getTracks(collection.trainTracks), id: "train-tracks", }), ), Svg.g( new SvgOptions({ attributes: { fill: "rgba(255, 68, 68, 1)", "pointer-events": "none", r: "0.003", stroke: "rgba(204, 0, 0, 1)", "stroke-width": "0.001", }, children: MapEl.#getStations(collection.trainStations), id: "train-stations", }), ), Svg.g( new SvgOptions({ attributes: { fill: "rgba(255, 255, 68, 1)", "pointer-events": "none", r: "0.003", stroke: "rgba(255, 255, 0, 1)", "stroke-width": "0.001", }, children: MapEl.#getStations(collection.lightRailStops), id: "light_rail", }), ), Svg.g( new SvgOptions({ attributes: { fill: "rgba(0, 255, 68, 1)", "pointer-events": "none", r: "0.003", stroke: "rgba(0, 255, 0, 1)", "stroke-width": "0.001", }, children: MapEl.#renderFeatures(collection.jokerTramStops), id: "tram-stations", }), ), Svg.g( new SvgOptions({ attributes: {}, children: [ ...MapEl.#getDistricts(collection.districts), ...MapEl.#getDistrictLabels(collection.districts), ], id: "districts", }), ), Svg.g( new SvgOptions({ attributes: { "pointer-events": "visiblePainted", r: "0.003", stroke: "rgba(51, 51, 51, 1)", "stroke-linecap": "butt", "stroke-width": "0.001", }, children: this.#getHouses(collection.houses, houseParameter), id: "houses", }), ), ], id: "map-transform", }), ); this.svg.append(transformGroup); const coastBounds = Bounds.union(collection.coastLine.features.map((f) => f.geometry.bounds())); const roadBounds = Bounds.union(collection.mainRoads.features.map((f) => f.geometry.bounds())); this.#fullBounds = Bounds.union([coastBounds, roadBounds]); return this.svg; } /** * Start inertia animation for panning * @param {SVGSVGElement} svg * @param {number} initVx - Initial velocity X * @param {number} initVy - Initial velocity Y * @param {number} [friction=PanningConfig.DEFAULT_FRICTION] * @param {number} [speedThreshold=PanningConfig.DEFAULT_SPEED_THRESHOLD] */ #startInertia( svg, initVx, initVy, friction = PanningConfig.DEFAULT_FRICTION, speedThreshold = PanningConfig.DEFAULT_SPEED_THRESHOLD, ) { let lastTime = performance.now(); let currVx = initVx; let currVy = initVy; const anim = () => { const now = performance.now(); const dt = now - lastTime; lastTime = now; const actualFriction = friction ** dt; currVx *= actualFriction; currVy *= actualFriction; const speed = Math.hypot(currVx, currVy); if (speed < speedThreshold) return; const deltaX = currVx * dt; const deltaY = currVy * dt; const vb = svg.viewBox.baseVal; vb.x -= deltaX; vb.y -= deltaY; const { clampedX, clampedY } = MapEl.#clampViewBox(svg, this.#fullBounds); if (clampedX) currVx = -currVx * PanningConfig.DEFAULT_BOUNCE_FACTOR; if (clampedY) currVy = -currVy * PanningConfig.DEFAULT_BOUNCE_FACTOR; requestAnimationFrame(anim); }; requestAnimationFrame(anim); } /** * Clamp viewBox to stay within bounds * @param {SVGSVGElement} svg * @param {Bounds|null} bounds * @returns {{clampedX: boolean, clampedY: boolean}} */ static #clampViewBox(svg, bounds) { if (!bounds) return { clampedX: false, clampedY: false }; const vb = svg.viewBox.baseVal; const oldX = vb.x; const oldY = vb.y; vb.x = MapMath.clamp(vb.x, bounds.minX, bounds.maxX - vb.width); vb.y = MapMath.clamp(vb.y, -bounds.maxY, -bounds.minY - vb.height); return { clampedX: vb.x !== oldX, clampedY: vb.y !== oldY, }; } /** * Render a LineString or MultiLineString feature as SVG path * @param {Feature} feature - GeoJSON feature with geometry * @param {Object} style - Style attributes * @param {string} [style.stroke] - Stroke color * @param {string} [style.fill] - Fill color * @returns {SVGPathElement|null} */ static #renderLineFeature(feature, style = {}) { const defaultStyle = { fill: "none", stroke: "rgba(0, 0, 0, 1)", ...style, }; if (feature.geometry instanceof LineString) { return Svg.path( feature.geometry.simplify(30), new SvgOptions({ attributes: defaultStyle, }), ); } else if (feature.geometry instanceof MultiLineString) { return Svg.path( new LineString(feature.geometry.simplify(30).coordinates.flat()), new SvgOptions({ attributes: defaultStyle, }), ); } return null; } /** * Create an SVG element with panning and zoom controls * @param {SVGSVGElement} svg */ #enableControls(svg) { let isDragging = false; /** @type {number|null} */ let pointerId = null; /** @type {number} */ let startX; /** @type {number} */ let startY; /** @type {number} */ let lastX; /** @type {number} */ let lastY; /** @type {number} */ let lastTime; let vx = 0, vy = 0; /** @type {SVGRect} */ let startViewBox; // Pinch zoom variables /** @type {Map} */ const pointers = new Map(); let initialDistance = 0; let isPinching = false; svg.addEventListener("pointerdown", (e) => { if (e.pointerType === "mouse" && e.button !== 0) return; // Only left mouse button pointers.set(e.pointerId, { clientX: e.clientX, clientY: e.clientY }); if (pointers.size === 2) { // Start pinch gesture isPinching = true; isDragging = false; const [p1, p2] = Array.from(pointers.values()); initialDistance = Math.hypot(p2.clientX - p1.clientX, p2.clientY - p1.clientY); } else if (!isPinching) { isDragging = true; pointerId = e.pointerId; svg.setPointerCapture(pointerId); startX = lastX = e.clientX; startY = lastY = e.clientY; lastTime = performance.now(); vx = vy = 0; startViewBox = svg.viewBox.baseVal; svg.setAttribute("style", "cursor: grabbing;"); } e.preventDefault(); }); svg.addEventListener("pointermove", (e) => { pointers.set(e.pointerId, { clientX: e.clientX, clientY: e.clientY }); if (isPinching && pointers.size === 2) { // Handle pinch zoom const [p1, p2] = Array.from(pointers.values()); const currentDistance = Math.hypot(p2.clientX - p1.clientX, p2.clientY - p1.clientY); if (initialDistance > 0) { const scaleFactor = currentDistance / initialDistance; // Calculate center point between the two pointers in SVG coordinates const centerX = (p1.clientX + p2.clientX) / 2; const centerY = (p1.clientY + p2.clientY) / 2; const ctm = svg.getScreenCTM(); if (ctm) { const point = svg.createSVGPoint(); point.x = centerX; point.y = centerY; const svgPoint = point.matrixTransform(ctm.inverse()); this.#zoom(scaleFactor, new Point(svgPoint.x, svgPoint.y)); initialDistance = currentDistance; } } } else if (isDragging && e.pointerId === pointerId) { const now = performance.now(); const dt = now - lastTime; const dx = e.clientX - lastX; const dy = e.clientY - lastY; if (dt > 0) { const ctm = svg.getScreenCTM()?.inverse(); if (ctm === undefined) { throw new Error("Unexpected"); } const svgDx = dx * ctm.a + dy * ctm.c; const svgDy = dx * ctm.b + dy * ctm.d; vx = svgDx / dt; vy = svgDy / dt; } const totalDx = e.clientX - startX; const totalDy = e.clientY - startY; const ctm = svg.getScreenCTM()?.inverse(); if (ctm === undefined) { throw new Error("Unexpected"); } const svgTotalDx = totalDx * ctm.a + totalDy * ctm.c; const svgTotalDy = totalDx * ctm.b + totalDy * ctm.d; const newMinX = startViewBox.x - svgTotalDx; const newMinY = startViewBox.y - svgTotalDy; svg.setAttribute( "viewBox", `${newMinX} ${newMinY} ${startViewBox.width} ${startViewBox.height}`, ); MapEl.#clampViewBox(svg, this.#fullBounds); lastX = e.clientX; lastY = e.clientY; lastTime = now; } e.preventDefault(); }); svg.addEventListener("pointerup", (e) => { pointers.delete(e.pointerId); if (isPinching && pointers.size < 2) { isPinching = false; initialDistance = 0; } if (e.pointerId === pointerId) { isDragging = false; pointerId = null; this.svg.releasePointerCapture(e.pointerId); this.svg.setAttribute("style", "cursor: grab;"); const speed = Math.hypot(vx, vy); if (speed > PanningConfig.DEFAULT_SPEED_THRESHOLD) { this.#startInertia(this.svg, vx, vy); } } }); svg.addEventListener("pointercancel", (e) => { pointers.delete(e.pointerId); if (isPinching && pointers.size < 2) { isPinching = false; initialDistance = 0; } if (e.pointerId === pointerId) { isDragging = false; pointerId = null; this.svg.releasePointerCapture(e.pointerId); this.svg.setAttribute("style", "cursor: grab;"); } }); // Mouse wheel zoom svg.addEventListener("wheel", (e) => { e.preventDefault(); const delta = -e.deltaY; const scaleFactor = delta > 0 ? 0.8 : 1.25; const ctm = svg.getScreenCTM(); if (ctm) { const point = svg.createSVGPoint(); point.x = e.clientX; point.y = e.clientY; const svgPoint = point.matrixTransform(ctm.inverse()); this.#zoom(scaleFactor, new Point(svgPoint.x, svgPoint.y)); } }); // Double-click zoom svg.addEventListener("dblclick", (e) => { e.preventDefault(); const ctm = svg.getScreenCTM(); if (ctm) { const point = svg.createSVGPoint(); point.x = e.clientX; point.y = e.clientY; const svgPoint = point.matrixTransform(ctm.inverse()); const scaleFactor = e.shiftKey ? 0.5 : 2; // Zoom out with shift, zoom in without this.#zoom(scaleFactor, new Point(svgPoint.x, svgPoint.y)); } }); } /** * Set houses data and render markers * @param {House[]} houses * @param {HouseParameter} param */ #getHouses(houses, param) { const values = houses.map((house) => house.get(param)).sort(); const range = { max: Math.max(...values), min: Math.min(...values) }; switch (param) { case HouseParameter.price: // No prices available for each house. Take some from the bottom range.min = values[Math.floor(values.length * 0.2)]; range.max = values[Math.floor(values.length * 0.8)]; } return houses.map((house) => { const value = house.get(param); const normalized = MapMath.normalize(value, range.min, range.max); const circle = Svg.circle( house.coordinates, new SvgOptions({ attributes: { "data-id": house.id, fill: Color.ocean(normalized), }, children: [ Svg.title(`${house.address}, ${house.district}\n€${house.price.toLocaleString()}`), ], classes: ["house-marker"], }), ); circle.addEventListener("mouseenter", () => { circle.setAttribute("r", "0.005"); clearTimeout(this.#modalTimer); if (this.#onHouseHover) { this.#onHouseHover(house.id, false); } }); circle.addEventListener("mouseleave", () => { circle.setAttribute("r", "0.003"); circle.setAttribute("stroke", "rgba(51, 51, 51, 1)"); circle.setAttribute("stroke-width", "0.001"); if (!this.#persistentModal && this.#onHouseHover) { this.#modalTimer = window.setTimeout(() => { if (this.#onHouseHover) { this.#onHouseHover(house.id, true); } }, 200); } }); circle.addEventListener("click", (e) => { e.stopPropagation(); if (this.#onHouseClick) { this.#onHouseClick(house.id, true); this.#persistentModal = true; } }); return circle; }); } /** * Set districts data and render polygons * @param {District[]} districts */ static #getDistricts(districts) { return districts.map((district) => { const poly = Svg.polygon( district.polygon.simplify(30), new SvgOptions({ attributes: { "data-id": district.name, fill: "none", // Changed from semi-transparent blue to transparent "pointer-events": "stroke", stroke: "rgba(85, 85, 85, 1)", "stroke-width": "0.001", }, }), ); poly.addEventListener("mouseenter", () => { poly.setAttribute("stroke", "rgba(51, 51, 51, 1)"); poly.setAttribute("stroke-width", "0.002"); }); poly.addEventListener("mouseleave", () => { poly.setAttribute("stroke", "rgba(85, 85, 85, 1)"); poly.setAttribute("stroke-width", "0.001"); }); return poly; }); } /** * Set districts data and render polygons * @param {District[]} districts */ static #getDistrictLabels(districts) { return districts.map((district) => { const center = district.polygon.centroid(); return Svg.text( center, district.name, new SvgOptions({ attributes: { "data-id": district.name, "dominant-baseline": "middle", "font-size": "0.005", "pointer-events": "none", "text-anchor": "middle", transform: `translate(${center.lng}, ${center.lat}) scale(1, -1) translate(${-center.lng}, ${-center.lat})`, }, }), ); }); } /** * Set statistical areas data and render polygons * @param {StatisticalArea[]} statAreas * @param {string} paramName */ static #getStatisticalAreas(statAreas, paramName) { const values = statAreas.map((area) => area.getValue(paramName)); const range = { max: Math.max(...values), min: Math.min(...values) }; return statAreas.map((area) => { const value = area.getValue(paramName); const normalized = MapMath.normalize(value, range.min, range.max); return Svg.polygon( area.polygon.simplify(30), new SvgOptions({ attributes: { "data-id": area.id, fill: !(paramName === AreaParam.none) ? Color.fall(normalized, true) : "rgba(0, 0, 0, 0)", "pointer-events": "none", stroke: "rgba(0, 0, 0, 0.3)", "stroke-width": "0.0003", }, children: [Svg.title(`${area.properties.nimi}\n${area.getDisplay(paramName)}`)], }), ); }); } /** * Set statistical area labels * @param {StatisticalArea[]} areas */ static #getStatisticalAreaLabels(areas) { return areas.map((area) => { const center = area.centroid; return Svg.text( center, area.properties.nimi, new SvgOptions({ attributes: { "data-id": area.id, "dominant-baseline": "middle", "font-size": "0.0025", // Half of district font size "pointer-events": "none", "text-anchor": "middle", transform: `translate(${center.lng}, ${center.lat}) scale(1, -1) translate(${-center.lng}, ${-center.lat})`, }, }), ); }); } /** * @param {FeatureCollection} roads */ static #getRoads(roads) { return roads.features .map((feature) => MapEl.#renderLineFeature(feature, { stroke: "rgba(0, 0, 0, 1)", }), ) .filter((x) => x !== null); } /** * @param {FeatureCollection} coastline */ static #getCoastLine(coastline) { return coastline.features .map((feature) => MapEl.#renderLineFeature(feature, { stroke: "rgba(25, 25, 112, 1)", }), ) .filter((x) => x !== null); } /** * @param {FeatureCollection} c */ static #renderFeatures(c) { return c.features .map((feature) => { if (feature.geometry instanceof MultiLineString) { return Svg.path(new LineString(feature.geometry.simplify(30).coordinates.flat())); } else if (feature.geometry instanceof LineString) { return Svg.path(feature.geometry.simplify(30)); } else if (feature.geometry instanceof Point) { return Svg.circle(feature.geometry); } return null; }) .filter((x) => x !== null); } /** * Set train infrastructure data * @param {TrainTracks[]} tracks */ static #getTracks(tracks) { return tracks.map((track) => { return Svg.path(track.lineString, new SvgOptions({})); }); } /** * @param {TrainStation[]} stations */ static #getStations(stations) { return stations.map((station) => { const exterior = station.polygon.getExterior(); const point = new Point(exterior[0][0], exterior[0][1]); return Svg.circle( point, new SvgOptions({ attributes: {}, }), ); }); } /** * Update house colors based on current color parameter * @param {HouseParameter} param */ updateHousesColor(param) { const values = this.#collection?.houses.map((house) => house.get(param)).sort(); const range = { max: Math.max(...values), min: Math.min(...values) }; switch (param) { case HouseParameter.price: // No prices available for each house. Take some from the bottom range.min = values[Math.floor(values.length * 0.2)]; range.max = values[Math.floor(values.length * 0.8)]; } const markers = this.#housesGroup?.querySelectorAll(".house-marker"); markers?.forEach((marker) => { const houseId = marker.id; const house = this.#collection?.houses.find((h) => h.id === houseId); if (house) { const value = house.get(param); const normalized = MapMath.normalize(value, range.min, range.max); marker.setAttribute("fill", Color.ocean(normalized)); } }); } /** * Update statistical area colors based on current area color parameter * @param {AreaParam} param */ updateArea(param) { const values = this.#collection?.statisticalAreas.map((area) => area.getValue(param)); const range = { max: Math.max(...values), min: Math.min(...values) }; const statAreaPolygons = this.svg.querySelectorAll("#statistical-areas polygon"); statAreaPolygons.forEach((polygon) => { const areaId = polygon.getAttribute("data-id"); const area = this.#collection?.statisticalAreas.find((a) => a.id === areaId); if (area) { const value = area.getValue(param); const normalized = MapMath.normalize(value, range.min, range.max); polygon.setAttribute( "fill", !(param === AreaParam.none) ? Color.fall(normalized, true) : "rgba(0, 0, 0, 0)", ); const tooltipText = `${area.properties.nimi}\n${area.getDisplay(param)}`; const title = polygon.querySelector("title"); if (title) { title.textContent = tooltipText; } } }); } /** * Update house visibility based on filtered house IDs * @param {string[]} filteredHouseIds */ updateHouseVisibility(filteredHouseIds) { const filteredSet = new Set(filteredHouseIds); const markers = this.#housesGroup?.querySelectorAll(".house-marker"); markers?.forEach((marker) => { const houseId = marker.id; marker.setAttribute("display", filteredSet.has(houseId) ? "" : "none"); }); } /** * Set modal persistence state * @param {boolean} persistent */ setModalPersistence(persistent) { this.#persistentModal = persistent; } /** * Clear modal timer */ clearModalTimer() { clearTimeout(this.#modalTimer); } } /** * Static class for perceptually uniform colormaps based on CMasher * Provides color mapping from value [0,1] to RGBA colors */ export class Color { /** * @param {number} value - Normalized value between 0 and 1 * @param {boolean} [reverse=false] - Reverse the colormap * @returns {string} RGBA color string */ static fall(value, reverse = false) { if (Number.isNaN(value) || value > 1 || value < 0) { throw new Error(`Fall, input must be a number between [0,1], got ${value}`); } const normalizedT = reverse ? 1 - value : value; return Color.#fall(normalizedT); } /** * @param {number} value - Normalized value between 0 and 1 * @param {boolean} [reverse=false] - Reverse the colormap * @returns {string} RGBA color string */ static ocean(value, reverse = false) { if (Number.isNaN(value) || value > 1 || value < 0) { throw new Error(`Ocean, input must be a number between [0,1], got ${value}`); } const normalizedT = reverse ? 1 - value : value; return Color.#ocean(normalizedT); } /** * @param {number} value - Normalized value between 0 and 1 * @param {boolean} [reverse=false] - Reverse the colormap * @returns {string} RGBA color string */ static lilac(value, reverse = false) { if (Number.isNaN(value) || value > 1 || value < 0) { throw new Error(`Ocean, input must be a number between [0,1], got ${value}`); } const normalizedT = reverse ? 1 - value : value; return Color.#lilac(normalizedT); } /** * @param {number} value - Normalized value between 0 and 1 * @param {boolean} [reverse=false] - Reverse the colormap * @returns {string} RGBA color string */ static bubblegum(value, reverse = false) { if (Number.isNaN(value) || value > 1 || value < 0) { throw new Error(`Ocean, input must be a number between [0,1], got ${value}`); } const normalizedT = reverse ? 1 - value : value; return Color.#bubblegum(normalizedT); } /** * Fall colormap - warm sequential colors * Based on CMasher fall colormap: warm colors from black through reds to yellow * @param {number} t - Normalized value [0,1] * @returns {string} RGBA color */ static #fall(t) { // CMasher fall: black -> dark red -> red -> orange -> yellow -> white const colors = [ [0.0, 0.0, 0.0], // black [0.1961, 0.0275, 0.0118], // dark red [0.5176, 0.102, 0.0431], // medium red [0.8235, 0.251, 0.0784], // bright red [0.9647, 0.5216, 0.149], // orange [0.9961, 0.7686, 0.3098], // yellow-orange [0.9961, 0.898, 0.5451], // light yellow [0.9882, 0.9608, 0.8157], // very light yellow/white ]; return Color.#interpolateColor(t, colors); } /** * Ocean colormap - cool sequential colors * Based on CMasher ocean colormap: dark blue to light blue/cyan * @param {number} t - Normalized value [0,1] * @returns {string} RGBA color */ static #ocean(t) { // CMasher ocean: black -> dark blue -> blue -> cyan -> light cyan const colors = [ [0.0, 0.0, 0.0], // black [0.0314, 0.0706, 0.1647], // dark blue [0.0627, 0.1843, 0.3843], // medium blue [0.0941, 0.3608, 0.6784], // blue [0.1098, 0.4824, 0.8627], // bright blue [0.3255, 0.6784, 0.949], // light blue [0.6, 0.8471, 0.9882], // cyan [0.851, 0.949, 0.9961], // light cyan ]; return Color.#interpolateColor(t, colors); } /** * Bubblegum colormap - pink/purple sequential colors * Based on CMasher bubblegum colormap: dark purple to light pink * @param {number} t - Normalized value [0,1] * @returns {string} RGBA color */ static #bubblegum(t) { // CMasher bubblegum: black -> dark purple -> purple -> pink -> light pink const colors = [ [0.0, 0.0, 0.0], // black [0.1412, 0.0392, 0.1804], // dark purple [0.2824, 0.0784, 0.3608], // purple [0.4235, 0.1176, 0.5412], // medium purple [0.6196, 0.1882, 0.6745], // pink-purple [0.8118, 0.3373, 0.7725], // pink [0.9373, 0.5765, 0.8431], // light pink [0.9882, 0.8, 0.898], // very light pink ]; return Color.#interpolateColor(t, colors); } /** * Lilac colormap - purple sequential colors * Based on CMasher lilac colormap: dark purple to light lilac * @param {number} t - Normalized value [0,1] * @returns {string} RGBA color */ static #lilac(t) { // CMasher lilac: black -> dark purple -> purple -> lilac -> light lilac const colors = [ [0.0, 0.0, 0.0], // black [0.0902, 0.0588, 0.1882], // dark purple [0.1725, 0.1098, 0.349], // medium dark purple [0.2941, 0.1725, 0.5176], // purple [0.4471, 0.2667, 0.6588], // lilac-purple [0.6235, 0.4078, 0.7725], // lilac [0.7843, 0.5882, 0.8627], // light lilac [0.9176, 0.7686, 0.9333], // very light lilac ]; return Color.#interpolateColor(t, colors); } /** * Interpolate between color points * @param {number} t - Normalized value [0,1] * @param {number[][]} colors - Array of RGB colors (0-1 range) * @returns {string} RGBA color string */ static #interpolateColor(t, colors) { const n = colors.length - 1; const segment = t * n; const index = Math.floor(segment); const localT = segment - index; if (index >= n) { // At or beyond the last color const [r, g, b] = colors[n]; return `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, 1)`; } // Linear interpolation between two colors const [r1, g1, b1] = colors[index]; const [r2, g2, b2] = colors[index + 1]; const r = r1 + (r2 - r1) * localT; const g = g1 + (g2 - g1) * localT; const b = b1 + (b2 - b1) * localT; return `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, 1)`; } }