diff options
Diffstat (limited to 'app/map.js')
| -rw-r--r-- | app/map.js | 549 |
1 files changed, 294 insertions, 255 deletions
@@ -1,6 +1,6 @@ -import { Bounds, Collection, LineString, MultiLineString, Point, Polygon } from "geom"; +import { Bounds, Collection, Feature, LineString, MultiLineString, Point } from "geom"; import { District, House, TrainStation, TrainTracks } from "models"; -import { Svg } from "svg"; +import { Svg, SvgOptions } from "svg"; /** * Color parameters for house markers @@ -14,6 +14,54 @@ export const ColorParameter = { }; /** + * 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; +} + +/** * Map component for rendering houses, districts, and train infrastructure */ export class MapEl { @@ -37,8 +85,8 @@ export class MapEl { #onHouseClick = null; /** @type {Function|null} */ #onHouseHover = null; - /** @type {number|null} */ - #modalTimer = null; + /** @type {number|undefined} */ + #modalTimer = undefined; /** @type {boolean} */ #persistentModal = false; /** @type {Bounds|null} */ @@ -50,28 +98,38 @@ export class MapEl { * @param {Function} [options.onHouseHover] */ constructor(options = {}) { - const svg = Svg.svg({ - attributes: { - preserveAspectRatio: "xMidYMid meet", - viewBox: "2400 6000 200 200", // longitude 24-26, latitude 60-62 - }, - }); - Object.assign(svg.style, { - background: "#e0e7ff", - cursor: "grab", - display: "block", - flex: "1", - minHeight: "0", - }); + 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", + }, + }), + ); - this.#background = Svg.g({ id: "background" }); - this.#trainTracksGroup = Svg.g({ id: "train-tracks" }); - this.#trainStationsGroup = Svg.g({ id: "train-stations" }); - this.#districtsGroup = Svg.g({ id: "districts" }); - this.#housesGroup = Svg.g({ - id: "houses", - styles: { pointerEvents: "auto" }, - }); + this.#housesGroup = Svg.g( + new SvgOptions({ + id: "houses", + }), + ); + this.#trainTracksGroup = Svg.g( + new SvgOptions({ + attributes: { + stroke: "rgba(255, 68, 68, 1)", + "stroke-width": "0.001", + }, + id: "train-tracks", + }), + ); + this.#trainStationsGroup = Svg.g(new SvgOptions({ id: "train-stations" })); + this.#districtsGroup = Svg.g(new SvgOptions({ id: "districts" })); + this.#background = Svg.g(new SvgOptions({ id: "background" })); this.#svg = svg; this.#onHouseClick = options.onHouseClick || null; @@ -83,24 +141,19 @@ export class MapEl { * @returns {SVGSVGElement} */ initializeMap() { - Svg.clear(this.#svg); - - // Apply W3C recommended transform for WGS84 coordinates - const transformGroup = Svg.g({ - attributes: { - transform: "scale(1, -1)", - }, - id: "map-transform", - }); - - // Create and store group handles + const transformGroup = Svg.g( + new SvgOptions({ + attributes: { transform: "scale(1, -1)" }, + id: "map-transform", + }), + ); transformGroup.append( + this.#background, + this.#districtsGroup, this.#trainTracksGroup, this.#trainStationsGroup, - this.#districtsGroup, this.#housesGroup, - this.#background, ); this.#svg.append(transformGroup); this.#enablePanning(this.#svg); @@ -109,10 +162,18 @@ export class MapEl { } /** - * @param {number} initVx - * @param {number} initVy + * Start inertia animation for panning + * @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(initVx, initVy) { + #startInertia( + initVx, + initVy, + friction = PanningConfig.DEFAULT_FRICTION, + speedThreshold = PanningConfig.DEFAULT_SPEED_THRESHOLD, + ) { let lastTime = performance.now(); let currVx = initVx; let currVy = initVy; @@ -122,27 +183,23 @@ export class MapEl { const dt = now - lastTime; lastTime = now; - // Apply friction (exponential decay, frame-independent) - const friction = 0.995 ** dt; // Adjust 0.995 for faster/slower stop (lower = more friction) - currVx *= friction; - currVy *= friction; + const actualFriction = friction ** dt; + currVx *= actualFriction; + currVy *= actualFriction; const speed = Math.hypot(currVx, currVy); - if (speed < 0.001) return; // Stop threshold + if (speed < speedThreshold) return; - // Compute delta const deltaX = currVx * dt; const deltaY = currVy * dt; - // Update viewBox const vb = this.#svg.viewBox.baseVal; vb.x -= deltaX; vb.y -= deltaY; - // Clamp and bounce on edge const { clampedX, clampedY } = this.#clampViewBox(); - if (clampedX) currVx = -currVx * 0.5; // Bounce (adjust 0.5 for elasticity) - if (clampedY) currVy = -currVy * 0.5; + if (clampedX) currVx = -currVx * PanningConfig.DEFAULT_BOUNCE_FACTOR; + if (clampedY) currVy = -currVy * PanningConfig.DEFAULT_BOUNCE_FACTOR; requestAnimationFrame(anim); }; @@ -151,17 +208,18 @@ export class MapEl { } /** - * @param {number} bounce + * Clamp viewBox to stay within bounds + * @returns {{clampedX: boolean, clampedY: boolean}} */ - #clampViewBox(bounce = 0) { + #clampViewBox() { if (!this.#fullBounds) return { clampedX: false, clampedY: false }; const vb = this.#svg.viewBox.baseVal; const oldX = vb.x; const oldY = vb.y; - vb.x = Math.max(this.#fullBounds.minX, Math.min(vb.x, this.#fullBounds.maxX - vb.width)); - vb.y = Math.max(-this.#fullBounds.maxY, Math.min(vb.y, -this.#fullBounds.minY - vb.height)); + vb.x = MapMath.clamp(vb.x, this.#fullBounds.minX, this.#fullBounds.maxX - vb.width); + vb.y = MapMath.clamp(vb.y, -this.#fullBounds.maxY, -this.#fullBounds.minY - vb.height); return { clampedX: vb.x !== oldX, @@ -170,20 +228,66 @@ export class MapEl { } /** + * 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.strokeWidth] - Stroke width + * @param {string} [style.fill] - Fill color + * @returns {SVGPathElement|null} + */ + static #renderLineFeature(feature, style = {}) { + const defaultStyle = { + fill: "none", + stroke: "rgba(0, 0, 0, 1)", + "stroke-width": "0.0005", + ...style, + }; + + if (feature.geometry instanceof LineString) { + return Svg.path( + feature.geometry, + new SvgOptions({ + attributes: defaultStyle, + }), + ); + } else if (feature.geometry instanceof MultiLineString) { + return Svg.path( + new LineString(feature.geometry.coordinates.flat()), + new SvgOptions({ + attributes: defaultStyle, + }), + ); + } + return null; + } + + /** * Create an SVG element * @param {SVGSVGElement} svg */ #enablePanning(svg) { let isDragging = false; + /** @type {number|null} */ let pointerId = null; - let startX, startY; - let lastX, lastY, lastTime; + /** @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; // Velocity in SVG units per ms + vy = 0; + /** @type {SVGRect} */ let startViewBox; svg.addEventListener("pointerdown", (e) => { - if (e.pointerType === "touch" && e.touches?.length > 1) return; + if (e.pointerType === "touch") return; isDragging = true; pointerId = e.pointerId; svg.setPointerCapture(pointerId); @@ -192,7 +296,7 @@ export class MapEl { lastTime = performance.now(); vx = vy = 0; startViewBox = svg.viewBox.baseVal; - svg.style.cursor = "grabbing"; + svg.setAttribute("style", "cursor: grabbing;"); e.preventDefault(); }); @@ -203,19 +307,25 @@ export class MapEl { const dx = e.clientX - lastX; const dy = e.clientY - lastY; - // Update velocity (for inertia) if (dt > 0) { - const ctm = svg.getScreenCTM().inverse(); + 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; } - // Total drag for precise panning const totalDx = e.clientX - startX; const totalDy = e.clientY - startY; - const ctm = svg.getScreenCTM().inverse(); + 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; @@ -226,7 +336,6 @@ export class MapEl { `${newMinX} ${newMinY} ${startViewBox.width} ${startViewBox.height}`, ); - // Clamp to bounds this.#clampViewBox(); lastX = e.clientX; @@ -240,12 +349,10 @@ export class MapEl { isDragging = false; pointerId = null; this.#svg.releasePointerCapture(e.pointerId); - this.#svg.style.cursor = "grab"; + this.#svg.setAttribute("style", "cursor: grab;"); - // Start inertia if velocity sufficient const speed = Math.hypot(vx, vy); - if (speed > 0.001) { - // Threshold (adjust as needed) + if (speed > PanningConfig.DEFAULT_SPEED_THRESHOLD) { this.#startInertia(vx, vy); } }); @@ -255,7 +362,7 @@ export class MapEl { isDragging = false; pointerId = null; this.#svg.releasePointerCapture(e.pointerId); - this.#svg.style.cursor = "grab"; + this.#svg.setAttribute("style", "cursor: grab;"); }); } @@ -269,31 +376,55 @@ export class MapEl { this.#colorParameter = colorParameter; const houseElements = houses.map((house) => { - const circle = Svg.circle(house.coordinates, { - attributes: { - "data-id": house.id, - r: 0.002, - }, - classes: ["house-marker"], - styles: { - cursor: "pointer", - fill: this.#getHouseColor(house), - stroke: "#333", - "stroke-width": "0.001", - }, - }); + const circle = Svg.circle( + house.coordinates, + new SvgOptions({ + attributes: { + "data-id": house.id, + fill: this.#getHouseColor(house), + "pointer-events": "visiblePainted", + r: "0.003", + stroke: "rgba(51, 51, 51, 1)", + "stroke-linecap": "butt", + "stroke-width": "0.001", + }, + classes: ["house-marker"], + styles: { cursor: "pointer" }, + }), + ); - // Add tooltip const tooltipText = `${house.address}, ${house.district}\n€${house.price.toLocaleString()}`; const title = Svg.title(tooltipText); circle.appendChild(title); - // Attach event listeners - circle.addEventListener("mouseenter", () => this.#onHouseMouseEnter(circle, house.id)); - circle.addEventListener("mouseleave", () => this.#onHouseMouseLeave(circle, house.id)); + 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 = setTimeout(() => { + if (this.#onHouseHover) { + this.#onHouseHover(house.id, true); + } + }, 200); + } + }); + circle.addEventListener("click", (e) => { e.stopPropagation(); - this.#onHouseClickCallback(circle, house.id); + if (this.#onHouseClick) { + this.#onHouseClick(house.id, true); + this.#persistentModal = true; + } }); return circle; @@ -303,75 +434,59 @@ export class MapEl { } /** - * Set houses data and render markers - * @param {District[]} districts - */ - static #calculateBounds(districts) { - const bounds = new Bounds(Infinity, Infinity, -Infinity, -Infinity); - - // Include districts in bounds - for (const district of districts) { - const districtBounds = district.polygon.bounds(); - bounds.minX = Math.min(districtBounds.minX, bounds.minX); - bounds.minY = Math.min(districtBounds.minY, bounds.minY); - bounds.maxX = Math.max(districtBounds.maxX, bounds.maxX); - bounds.maxY = Math.max(districtBounds.maxY, bounds.maxY); - } - return bounds; - } - - /** * Set districts data and render polygons * @param {District[]} districts */ setDistricts(districts) { const polygonElements = districts.map((district) => { - const poly = Svg.polygon(district.polygon, { - attributes: { - "data-id": district.name, - fill: "rgba(100,150,255,0.2)", - stroke: "#555", - "stroke-width": 0.001, - }, - classes: ["district"], - }); + const poly = Svg.polygon( + district.polygon, + new SvgOptions({ + attributes: { + "data-id": district.name, + fill: "rgba(100, 150, 255, 0.2)", + stroke: "rgba(85, 85, 85, 1)", + "stroke-width": "0.001", + }, + classes: ["district"], + }), + ); poly.addEventListener("mouseenter", () => { - poly.style.fill = "rgba(100,150,255,0.4)"; - poly.style.stroke = "#333"; + 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.style.fill = "rgba(100,150,255,0.2)"; - poly.style.stroke = "#555"; + poly.setAttribute("fill", "rgba(100, 150, 255, 0.2)"); + poly.setAttribute("stroke", "rgba(85, 85, 85, 1)"); poly.setAttribute("stroke-width", "0.001"); }); return poly; }); - // Render district labels const labelElements = districts.map((district) => { const center = district.polygon.centroid(); - return Svg.text(center, district.name, { - attributes: { - "data-id": district.name, - transform: `translate(${center.lng}, ${center.lat}) scale(1, -1) translate(${-center.lng}, ${-center.lat})`, - }, - classes: ["district-label"], - styles: { - dominantBaseline: "middle", - fill: "#333", - fontSize: "0.005", - pointerEvents: "none", - textAnchor: "middle", - }, - }); + 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})`, + }, + classes: ["district-label"], + }), + ); }); - const bounds = MapEl.#calculateBounds(districts); - this.#fullBounds = bounds; + const bounds = District.bounds(districts); this.#updateViewBox(bounds); this.#replaceGroupContent(this.#districtsGroup, [...polygonElements, ...labelElements]); } @@ -382,51 +497,28 @@ export class MapEl { */ setMapData(coastline, mainRoads) { const coastLinePaths = coastline.features - .map((feature) => { - if (feature.geometry instanceof LineString) { - return Svg.path(feature.geometry, { - attributes: { - stroke: "#191970", - "stroke-width": 0.001, - }, - }); - } else if (feature.geometry instanceof MultiLineString) { - return Svg.path(new LineString(feature.geometry.coordinates.flat()), { - attributes: { - stroke: "#191970", - "stroke-width": 0.001, - }, - }); - } else { - return null; - } - }) + .map((feature) => + MapEl.#renderLineFeature(feature, { + stroke: "rgba(25, 25, 112, 1)", + strokeWidth: "0.0005", + }), + ) .filter((x) => x !== null); const mainRoadPaths = mainRoads.features - .map((feature) => { - if (feature.geometry instanceof LineString) { - return Svg.path(feature.geometry, { - attributes: { - stroke: "#000000", - "stroke-width": 0.0005, - }, - }); - } else if (feature.geometry instanceof MultiLineString) { - return Svg.path(new LineString(feature.geometry.coordinates.flat()), { - attributes: { - stroke: "#000000", - "stroke-width": 0.0005, - }, - }); - } else { - return null; - } - }) + .map((feature) => + MapEl.#renderLineFeature(feature, { + stroke: "rgba(0, 0, 0, 1)", + strokeWidth: "0.0005", + }), + ) .filter((x) => x !== null); - this.#background.setAttribute("fill", "none"); this.#replaceGroupContent(this.#background, [...coastLinePaths, ...mainRoadPaths]); + + const coastBounds = Bounds.union(coastline.features.map((f) => f.geometry.bounds())); + const roadBounds = Bounds.union(mainRoads.features.map((f) => f.geometry.bounds())); + this.#fullBounds = Bounds.union([coastBounds, roadBounds]); } /** @@ -436,33 +528,27 @@ export class MapEl { */ setTrainData(stations, tracks) { const trackElements = tracks.map((track) => { - return Svg.path(track.lineString, { - attributes: { - stroke: "#ff4444", - "stroke-width": 0.001, - }, - classes: ["train-track"], - }); + return Svg.path(track.lineString, new SvgOptions({})); }); - this.#trainTracksGroup.setAttribute("fill", "none"); this.#replaceGroupContent(this.#trainTracksGroup, trackElements); const stationElements = stations.map((station) => { const exterior = station.polygon.getExterior(); const point = new Point(exterior[0][0], exterior[0][1]); - return Svg.circle(point, { - attributes: { - r: 0.003, - }, - classes: ["train-station"], - styles: { - fill: "#ff4444", - stroke: "#cc0000", - "stroke-width": "0.001", - }, - }); + return Svg.circle( + point, + new SvgOptions({ + attributes: { + fill: "rgba(255, 68, 68, 1)", + r: "0.003", + stroke: "rgba(204, 0, 0, 1)", + "stroke-width": "0.001", + }, + classes: ["train-station"], + }), + ); }); this.#replaceGroupContent(this.#trainStationsGroup, stationElements); @@ -477,11 +563,11 @@ export class MapEl { const markers = this.#housesGroup.querySelectorAll(".house-marker"); markers.forEach((marker) => { - const houseId = marker.dataset.id; + const houseId = marker.id; const house = this.#houses.find((h) => h.id === houseId); if (house) { const color = this.#getHouseColor(house); - marker.style.fill = color; + marker.setAttribute("fill", color); } }); } @@ -495,8 +581,8 @@ export class MapEl { const markers = this.#housesGroup.querySelectorAll(".house-marker"); markers.forEach((marker) => { - const houseId = marker.dataset.id; - marker.style.display = filteredSet.has(houseId) ? "" : "none"; + const houseId = marker.id; + marker.setAttribute("display", filteredSet.has(houseId) ? "" : "none"); }); } @@ -538,53 +624,6 @@ export class MapEl { } /** - * House mouse enter handler - * @param {SVGCircleElement} marker - * @param {string} houseId - */ - #onHouseMouseEnter(marker, houseId) { - marker.setAttribute("r", "0.01"); - marker.style.stroke = "#000"; - marker.setAttribute("stroke-width", "0.01"); - - clearTimeout(this.#modalTimer); - if (this.#onHouseHover) { - this.#onHouseHover(houseId, false); - } - } - - /** - * House mouse leave handler - * @param {SVGCircleElement} marker - * @param {string} houseId - */ - #onHouseMouseLeave(marker, houseId) { - marker.setAttribute("r", "0.003"); - marker.style.stroke = "#333"; - marker.setAttribute("stroke-width", "0.001"); - - if (!this.#persistentModal && this.#onHouseHover) { - this.#modalTimer = setTimeout(() => { - if (this.#onHouseHover) { - this.#onHouseHover(houseId, true); - } - }, 200); - } - } - - /** - * House click handler - * @param {SVGCircleElement} marker - * @param {string} houseId - */ - #onHouseClickCallback(marker, houseId) { - if (this.#onHouseClick) { - this.#onHouseClick(houseId, true); - this.#persistentModal = true; - } - } - - /** * Get color for house based on parameter value * @param {House} house * @returns {string} @@ -614,10 +653,10 @@ export class MapEl { max = 200; break; default: - return "#4caf50"; + return "rgba(76, 175, 80, 1)"; } - const normalized = Math.max(0, Math.min(1, (value - min) / (max - min))); + const normalized = MapMath.normalize(value, min, max); return MapEl.#gradientColor(normalized); } @@ -629,16 +668,16 @@ export class MapEl { static #gradientColor(normalized) { if (normalized < 0.5) { const t = normalized * 2; - const r = Math.round(42 + (87 - 42) * t); - const g = Math.round(123 + (199 - 123) * t); - const b = Math.round(155 + (133 - 155) * t); - return `rgb(${r}, ${g}, ${b})`; + 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(87 + (237 - 87) * t); - const g = Math.round(199 + (221 - 199) * t); - const b = Math.round(133 + (83 - 133) * t); - return `rgb(${r}, ${g}, ${b})`; + 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)`; } } } |
