import { Bounds, Collection, Feature, LineString, MultiLineString, Point } from "geom"; import { District, House, TrainStation, TrainTracks } from "models"; import { Svg, SvgOptions } from "svg"; /** * Color parameters for house markers * @enum {string} */ export const ColorParameter = { area: "livingArea", price: "price", score: "score", year: "constructionYear", }; /** * 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 { /** @type {SVGSVGElement} */ #svg; /** @type {SVGGElement} */ #housesGroup; /** @type {SVGGElement} */ #districtsGroup; /** @type {SVGGElement} */ #trainTracksGroup; /** @type {SVGGElement} */ #background; /** @type {SVGGElement} */ #trainStationsGroup; /** @type {House[]} */ #houses = []; /** @type {string} */ #colorParameter = ColorParameter.price; /** @type {Function|null} */ #onHouseClick = null; /** @type {Function|null} */ #onHouseHover = null; /** @type {number|undefined} */ #modalTimer = undefined; /** @type {boolean} */ #persistentModal = false; /** @type {Bounds|null} */ #fullBounds = null; /** * @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", }, }), ); 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; this.#onHouseHover = options.onHouseHover || null; } /** * Initialize map with empty content * @returns {SVGSVGElement} */ initializeMap() { 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.#housesGroup, ); this.#svg.append(transformGroup); this.#enablePanning(this.#svg); return this.#svg; } /** * 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, 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 = this.#svg.viewBox.baseVal; vb.x -= deltaX; vb.y -= deltaY; const { clampedX, clampedY } = this.#clampViewBox(); 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 * @returns {{clampedX: boolean, clampedY: boolean}} */ #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 = 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, 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.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; /** @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; 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;"); 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; 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}`, ); this.#clampViewBox(); lastX = e.clientX; lastY = e.clientY; lastTime = now; e.preventDefault(); }); 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(vx, vy); } }); svg.addEventListener("pointercancel", (e) => { if (e.pointerId !== pointerId) return; isDragging = false; pointerId = null; this.#svg.releasePointerCapture(e.pointerId); this.#svg.setAttribute("style", "cursor: grab;"); }); } /** * Set houses data and render markers * @param {House[]} houses * @param {string} [colorParameter=this.#colorParameter] */ setHouses(houses, colorParameter = this.#colorParameter) { this.#houses = houses; this.#colorParameter = colorParameter; const houseElements = houses.map((house) => { 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" }, }), ); const tooltipText = `${house.address}, ${house.district}\n€${house.price.toLocaleString()}`; const title = Svg.title(tooltipText); circle.appendChild(title); 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(); if (this.#onHouseClick) { this.#onHouseClick(house.id, true); this.#persistentModal = true; } }); return circle; }); this.#replaceGroupContent(this.#housesGroup, houseElements); } /** * Set districts data and render polygons * @param {District[]} districts */ setDistricts(districts) { const polygonElements = districts.map((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.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"); }); return poly; }); const labelElements = 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})`, }, classes: ["district-label"], }), ); }); const bounds = District.bounds(districts); this.#updateViewBox(bounds); this.#replaceGroupContent(this.#districtsGroup, [...polygonElements, ...labelElements]); } /** * @param {Collection} coastline * @param {Collection} mainRoads */ setMapData(coastline, mainRoads) { const coastLinePaths = coastline.features .map((feature) => MapEl.#renderLineFeature(feature, { stroke: "rgba(25, 25, 112, 1)", strokeWidth: "0.0005", }), ) .filter((x) => x !== null); const mainRoadPaths = mainRoads.features .map((feature) => MapEl.#renderLineFeature(feature, { stroke: "rgba(0, 0, 0, 1)", strokeWidth: "0.0005", }), ) .filter((x) => x !== null); 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]); } /** * Set train infrastructure data * @param {TrainStation[]} stations * @param {TrainTracks[]} tracks */ setTrainData(stations, tracks) { const trackElements = tracks.map((track) => { return Svg.path(track.lineString, new SvgOptions({})); }); 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, 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); } /** * Update house colors based on current color parameter * @param {string} colorParameter */ setColorParameter(colorParameter) { this.#colorParameter = colorParameter; const markers = this.#housesGroup.querySelectorAll(".house-marker"); markers.forEach((marker) => { const houseId = marker.id; const house = this.#houses.find((h) => h.id === houseId); if (house) { const color = this.#getHouseColor(house); marker.setAttribute("fill", color); } }); } /** * 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); } /** * Replace all content in a group with new elements * @param {SVGGElement} group * @param {SVGElement[]} elements */ #replaceGroupContent(group, elements) { Svg.clear(group); group.append(...elements); } /** * Update the viewBox based on the actual content bounds * @param {Bounds} bounds */ #updateViewBox(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; this.#svg.setAttribute("viewBox", `${bounds.minX} ${-bounds.maxY} ${width} ${height}`); } /** * Get color for house based on parameter value * @param {House} house * @returns {string} */ #getHouseColor(house) { let value, min, max; switch (this.#colorParameter) { case ColorParameter.price: value = house.price; min = 0; max = 1000000; break; case ColorParameter.score: value = house.scores.current; min = 0; max = 100; break; case ColorParameter.year: value = house.constructionYear || 1950; min = 1950; max = new Date().getFullYear(); break; case ColorParameter.area: value = house.livingArea; min = 0; max = 200; break; default: return "rgba(76, 175, 80, 1)"; } 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)`; } } }