aboutsummaryrefslogtreecommitdiffstats
path: root/app/map.js
diff options
context:
space:
mode:
authorPetri Hienonen <petri.hienonen@gmail.com>2025-11-09 22:59:02 +0200
committerPetri Hienonen <petri.hienonen@gmail.com>2025-11-11 15:35:03 +0200
commit909773f9d253c61183cc1f9f6193656957946be5 (patch)
tree136075e1946accedda0530dd25940b8931408c5a /app/map.js
parentbe7ec90b500ac68e053f2b58feb085247ef95817 (diff)
downloadhousing-909773f9d253c61183cc1f9f6193656957946be5.tar.zst
Add statistical areas
Diffstat (limited to 'app/map.js')
-rw-r--r--app/map.js714
1 files changed, 630 insertions, 84 deletions
diff --git a/app/map.js b/app/map.js
index 2658df9..2259b60 100644
--- a/app/map.js
+++ b/app/map.js
@@ -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})`);
}
}