diff options
| author | Petri Hienonen <petri.hienonen@gmail.com> | 2025-11-09 22:59:02 +0200 |
|---|---|---|
| committer | Petri Hienonen <petri.hienonen@gmail.com> | 2025-11-11 15:35:03 +0200 |
| commit | 909773f9d253c61183cc1f9f6193656957946be5 (patch) | |
| tree | 136075e1946accedda0530dd25940b8931408c5a /app/map.js | |
| parent | be7ec90b500ac68e053f2b58feb085247ef95817 (diff) | |
| download | housing-909773f9d253c61183cc1f9f6193656957946be5.tar.zst | |
Add statistical areas
Diffstat (limited to 'app/map.js')
| -rw-r--r-- | app/map.js | 714 |
1 files changed, 630 insertions, 84 deletions
@@ -1,5 +1,5 @@ import { Bounds, Collection, Feature, LineString, MultiLineString, Point } from "geom"; -import { District, House, TrainStation, TrainTracks } from "models"; +import { District, House, StatisticalArea, TrainStation, TrainTracks } from "models"; import { Svg, SvgOptions } from "svg"; /** @@ -14,6 +14,18 @@ export const ColorParameter = { }; /** + * Area color parameters for statistical areas + * @enum {string} + */ +export const AreaColorParameter = { + averageIncome: "averageIncome", + foreignSpeakers: "foreignSpeakers", + higherEducation: "higherEducation", + none: "none", + unemploymentRate: "unemploymentRate", +}; + +/** * Math utility functions */ export class MapMath { @@ -66,8 +78,12 @@ export class MapEl { svg; /** @type {House[]} */ #houses = []; + /** @type {StatisticalArea[]} */ + #statAreas = []; /** @type {SVGGElement|null} */ #housesGroup = null; + /** @type {SVGGElement|null} */ + #statAreasGroup = null; /** @type {Function|null} */ #onHouseClick; /** @type {Function} */ @@ -78,6 +94,14 @@ export class MapEl { #persistentModal = false; /** @type {Bounds|null} */ #fullBounds = null; + /** @type {Point|null} */ + #centerPoint = null; + /** @type {number} */ + #viewHeightMeters = 10000; // Initial view height in meters + /** @type {string} */ + #areaColorParameter = AreaColorParameter.none; + /** @type {Object} */ + #statAreaRanges = {}; /** * @param {Object} options @@ -96,6 +120,7 @@ export class MapEl { display: "block", flex: "1", minHeight: "0", + touchAction: "none", // Important for pinch zoom }, }), ); @@ -103,7 +128,7 @@ export class MapEl { this.svg = svg; this.#onHouseClick = options.onHouseClick; this.#onHouseHover = options.onHouseHover; - this.#enablePanning(this.svg); + this.#enableControls(this.svg); } /** @@ -114,10 +139,102 @@ export class MapEl { 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); + } + + /** + * Calculate min/max ranges for statistical area values + * @param {StatisticalArea[]} statAreas + */ + #calculateStatAreaRanges(statAreas) { + this.#statAreaRanges = {}; + + // Calculate ranges for each parameter type + const parameters = [ + AreaColorParameter.foreignSpeakers, + AreaColorParameter.unemploymentRate, + AreaColorParameter.averageIncome, + AreaColorParameter.higherEducation, + ]; + + for (const param of parameters) { + const values = statAreas.map((area) => MapEl.#getStatisticalAreaValue(area, param)); + const min = Math.min(...values); + const max = Math.max(...values); + this.#statAreaRanges[param] = { max, min }; + } + } + + /** * Initialize map with empty content * @param {District[]} districts * @param {Collection} coastLine @@ -125,11 +242,14 @@ export class MapEl { * @param {TrainTracks[]} tracks * @param {TrainStation[]} stations * @param {House[]} houses + * @param {StatisticalArea[]} statAreas * @param {string} colorParameter * @returns {SVGSVGElement} */ - initialize(districts, coastLine, mainRoads, tracks, stations, houses, colorParameter) { + initialize(districts, coastLine, mainRoads, tracks, stations, houses, statAreas, colorParameter) { this.#houses = houses; + this.#statAreas = statAreas; + this.#calculateStatAreaRanges(statAreas); this.#setInitialViewBox(District.bounds(districts)); const transformGroup = Svg.g( new SvgOptions({ @@ -139,6 +259,22 @@ export class MapEl { new SvgOptions({ attributes: { "pointer-events": "none", + }, + children: [ + ...MapEl.#getStatisticalAreas( + statAreas, + this.#areaColorParameter, + this.#statAreaRanges, + ), + ...MapEl.#getStatisticalAreaLabels(statAreas), + ], + id: "statistical-areas", + }), + ), + Svg.g( + new SvgOptions({ + attributes: { + "pointer-events": "none", "stroke-width": "0.0005", }, children: [...MapEl.#getCoastLine(coastLine), ...MapEl.#getRoads(mainRoads)], @@ -306,10 +442,10 @@ export class MapEl { } /** - * Create an SVG element + * Create an SVG element with panning and zoom controls * @param {SVGSVGElement} svg */ - #enablePanning(svg) { + #enableControls(svg) { let isDragging = false; /** @type {number|null} */ let pointerId = null; @@ -329,83 +465,176 @@ export class MapEl { /** @type {SVGRect} */ let startViewBox; + // Pinch zoom variables + /** @type {Map<number, {clientX: number, clientY: number}>} */ + const pointers = new Map(); + let initialDistance = 0; + let isPinching = false; + svg.addEventListener("pointerdown", (e) => { - if (e.pointerType === "touch") return; - 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;"); + 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) => { - if (!isDragging || e.pointerId !== pointerId) return; - const now = performance.now(); - const dt = now - lastTime; - const dx = e.clientX - lastX; - const dy = e.clientY - lastY; + pointers.set(e.pointerId, { clientX: e.clientX, clientY: e.clientY }); - if (dt > 0) { + 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 svgDx = dx * ctm.a + dy * ctm.c; - const svgDy = dx * ctm.b + dy * ctm.d; - vx = svgDx / dt; - vy = svgDy / dt; + 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); - const totalDx = e.clientX - startX; - const totalDy = e.clientY - startY; - const ctm = svg.getScreenCTM()?.inverse(); - if (ctm === undefined) { - throw new Error("Unexpected"); + if (isPinching && pointers.size < 2) { + isPinching = false; + initialDistance = 0; } - const svgTotalDx = totalDx * ctm.a + totalDy * ctm.c; - const svgTotalDy = totalDx * ctm.b + totalDy * ctm.d; + if (e.pointerId === pointerId) { + isDragging = false; + pointerId = null; + this.svg.releasePointerCapture(e.pointerId); + this.svg.setAttribute("style", "cursor: grab;"); - const newMinX = startViewBox.x - svgTotalDx; - const newMinY = startViewBox.y - svgTotalDy; - svg.setAttribute( - "viewBox", - `${newMinX} ${newMinY} ${startViewBox.width} ${startViewBox.height}`, - ); + const speed = Math.hypot(vx, vy); + if (speed > PanningConfig.DEFAULT_SPEED_THRESHOLD) { + this.#startInertia(this.svg, vx, vy); + } + } + }); - MapEl.#clampViewBox(svg, this.#fullBounds); + svg.addEventListener("pointercancel", (e) => { + pointers.delete(e.pointerId); - lastX = e.clientX; - lastY = e.clientY; - lastTime = now; - e.preventDefault(); + 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;"); + } }); - svg.addEventListener("pointerup", (e) => { - if (e.pointerId !== pointerId) return; - 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); + // 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)); } }); - svg.addEventListener("pointercancel", (e) => { - if (e.pointerId !== pointerId) return; - isDragging = false; - pointerId = null; - this.svg.releasePointerCapture(e.pointerId); - this.svg.setAttribute("style", "cursor: grab;"); + // 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)); + } }); } @@ -445,7 +674,7 @@ export class MapEl { circle.setAttribute("stroke-width", "0.001"); if (!this.#persistentModal && this.#onHouseHover) { - this.#modalTimer = setTimeout(() => { + this.#modalTimer = window.setTimeout(() => { if (this.#onHouseHover) { this.#onHouseHover(house.id, true); } @@ -474,7 +703,7 @@ export class MapEl { new SvgOptions({ attributes: { "data-id": district.name, - fill: "rgba(100, 150, 255, 0.2)", + fill: "none", // Changed from semi-transparent blue to transparent "pointer-events": "stroke", stroke: "rgba(85, 85, 85, 1)", "stroke-width": "0.001", @@ -483,13 +712,11 @@ export class MapEl { ); poly.addEventListener("mouseenter", () => { - poly.setAttribute("fill", "rgba(100, 150, 255, 0.4)"); poly.setAttribute("stroke", "rgba(51, 51, 51, 1)"); poly.setAttribute("stroke-width", "0.002"); }); poly.addEventListener("mouseleave", () => { - poly.setAttribute("fill", "rgba(100, 150, 255, 0.2)"); poly.setAttribute("stroke", "rgba(85, 85, 85, 1)"); poly.setAttribute("stroke-width", "0.001"); }); @@ -523,6 +750,128 @@ export class MapEl { } /** + * Set statistical areas data and render polygons + * @param {StatisticalArea[]} statAreas + * @param {string} areaColorParameter + * @param {Object} ranges + */ + static #getStatisticalAreas(statAreas, areaColorParameter, ranges) { + return statAreas.map((area) => { + const color = MapEl.#getStatisticalAreaColor(area, areaColorParameter, ranges); + const value = MapEl.#getStatisticalAreaValue(area, areaColorParameter); + + const poly = Svg.polygon( + area.polygon.simplify(30), + new SvgOptions({ + attributes: { + "data-id": area.id, + fill: color, + "pointer-events": "none", + stroke: "rgba(0, 0, 0, 0.3)", + "stroke-width": "0.0003", + }, + }), + ); + + // Add tooltip with area name and value + const tooltipText = `${area.properties.nimi}\n${MapEl.#getStatisticalAreaDisplayText(areaColorParameter, value)}`; + const title = Svg.title(tooltipText); + poly.appendChild(title); + + return poly; + }); + } + + /** + * Set statistical area labels + * @param {StatisticalArea[]} statAreas + */ + static #getStatisticalAreaLabels(statAreas) { + return statAreas.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})`, + }, + }), + ); + }); + } + + /** + * Get color for statistical area based on parameter value + * @param {StatisticalArea} area + * @param {string} areaColorParameter + * @param {Object} ranges + * @returns {string} + */ + static #getStatisticalAreaColor(area, areaColorParameter, ranges) { + if (areaColorParameter === AreaColorParameter.none) { + return "rgba(0, 0, 0, 0)"; // Transparent + } + + const value = MapEl.#getStatisticalAreaValue(area, areaColorParameter); + const range = ranges[areaColorParameter]; + const normalized = range ? MapMath.normalize(value, range.min, range.max) : 0; + return Color.get("fall", normalized, true); + } + + /** + * Get value for statistical area based on parameter + * @param {StatisticalArea} area + * @param {string} areaColorParameter + * @returns {number} + */ + static #getStatisticalAreaValue(area, areaColorParameter) { + const props = area.properties; + + switch (areaColorParameter) { + case AreaColorParameter.foreignSpeakers: + return props.vr_kiel_vier / props.vr_vakiy; + case AreaColorParameter.unemploymentRate: + return props.tp_tyotaste; + case AreaColorParameter.averageIncome: + return props.tu_kesk; + case AreaColorParameter.higherEducation: + return props.ko_yl_kork / props.ko_25_; + default: + return 0; + } + } + + /** + * Get display text for statistical area tooltip + * @param {string} areaColorParameter + * @param {number} value + * @returns {string} + */ + static #getStatisticalAreaDisplayText(areaColorParameter, value) { + if (!(typeof value === "number")) { + return "NaN"; + } + switch (areaColorParameter) { + case AreaColorParameter.foreignSpeakers: + return `Foreign speakers: ${(value * 100).toFixed(1)}%`; + case AreaColorParameter.unemploymentRate: + return `Unemployment rate: ${value.toFixed(1)}%`; + case AreaColorParameter.averageIncome: + return `Average income: ${Math.round(value).toLocaleString()} €`; + case AreaColorParameter.higherEducation: + return `Higher education: ${(value * 100).toFixed(1)}%`; + default: + return ""; + } + } + + /** * @param {Collection} roads */ static #getRoads(roads) { @@ -591,6 +940,36 @@ export class MapEl { } /** + * Update statistical area colors based on current area color parameter + * @param {string} areaColorParameter + */ + setAreaColorParameter(areaColorParameter) { + this.#areaColorParameter = areaColorParameter; + + const statAreaPolygons = this.svg.querySelectorAll("#statistical-areas polygon"); + statAreaPolygons.forEach((polygon) => { + const areaId = polygon.getAttribute("data-id"); + const area = this.#statAreas.find((a) => a.id === areaId); + if (area) { + const color = MapEl.#getStatisticalAreaColor( + area, + areaColorParameter, + this.#statAreaRanges, + ); + polygon.setAttribute("fill", color); + + // Update tooltip + const value = MapEl.#getStatisticalAreaValue(area, areaColorParameter); + const tooltipText = `${area.properties.nimi}\n${MapEl.#getStatisticalAreaDisplayText(areaColorParameter, value)}`; + const title = polygon.querySelector("title"); + if (title) { + title.textContent = tooltipText; + } + } + }); + } + + /** * Update house visibility based on filtered house IDs * @param {string[]} filteredHouseIds */ @@ -654,27 +1033,194 @@ export class MapEl { } const normalized = MapMath.normalize(value, min, max); - return MapEl.#gradientColor(normalized); - } - - /** - * Calculate gradient color based on normalized value - * @param {number} normalized - * @returns {string} color - */ - static #gradientColor(normalized) { - if (normalized < 0.5) { - const t = normalized * 2; - const r = Math.round(MapMath.lerp(42, 87, t)); - const g = Math.round(MapMath.lerp(123, 199, t)); - const b = Math.round(MapMath.lerp(155, 133, t)); - return `rgba(${r}, ${g}, ${b}, 1)`; - } else { - const t = (normalized - 0.5) * 2; - const r = Math.round(MapMath.lerp(87, 237, t)); - const g = Math.round(MapMath.lerp(199, 221, t)); - const b = Math.round(MapMath.lerp(133, 83, t)); - return `rgba(${r}, ${g}, ${b}, 1)`; + return Color.get("ocean", normalized); + } +} + +/** + * Static class for perceptually uniform colormaps based on CMasher + * Provides color mapping from value [0,1] to RGBA colors + */ +export class Color { + /** + * Get color from specified colormap + * @param {string} colormap - Name of colormap ('fall', 'ocean', 'bubblegum', 'lilac') + * @param {number} value - Normalized value between 0 and 1 + * @param {boolean} [reverse=false] - Reverse the colormap + * @returns {string} RGBA color string + */ + static get(colormap, value, reverse = false) { + if (Number.isNaN(value) || value > 1 || value < 0) { + throw new Error(`Input must be a number between [0,1] ${value}`); } + const normalizedT = reverse ? 1 - value : value; + + switch (colormap.toLowerCase()) { + case "fall": + return Color.#fall(normalizedT); + case "ocean": + return Color.#ocean(normalizedT); + case "bubblegum": + return Color.#bubblegum(normalizedT); + case "lilac": + return Color.#lilac(normalizedT); + default: + throw new Error(`Unknown colormap: ${colormap}`); + } + } + + /** + * 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)`; + } + + /** + * Get all available colormap names + * @returns {string[]} Array of colormap names + */ + static getColormapNames() { + return ["fall", "ocean", "bubblegum", "lilac"]; + } + + /** + * Generate a color scale for testing/visualization + * @param {string} colormap - Name of colormap + * @param {number} steps - Number of steps in the scale + * @param {boolean} [reverse=false] - Reverse the colormap + * @returns {string[]} Array of RGBA colors + */ + static generateColorScale(colormap, steps = 10, reverse = false) { + const colors = []; + for (let i = 0; i < steps; i++) { + const t = i / (steps - 1); + colors.push(Color.get(colormap, t, reverse)); + } + return colors; + } + + /** + * Get color with custom alpha value + * @param {string} colormap - Name of colormap + * @param {number} value - Normalized value between 0 and 1 + * @param {number} alpha - Alpha value between 0 and 1 + * @param {boolean} [reverse=false] - Reverse the colormap + * @returns {string} RGBA color string + */ + static getColorWithAlpha(colormap, value, alpha, reverse = false) { + const color = Color.get(colormap, value, reverse); + // Replace the alpha value in the rgba string + return color.replace(/[\d.]+\)$/, `${alpha})`); } } |
