import { Bounds, Collection, LineString, MultiLineString, Point, Polygon } from "geom"; import { District, House, TrainStation, TrainTracks } from "models"; import { Svg } from "svg"; /** * Color parameters for house markers * @enum {string} */ export const ColorParameter = { area: "livingArea", price: "price", score: "score", year: "constructionYear", }; /** * 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|null} */ #modalTimer = null; /** @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({ 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", }); 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.#svg = svg; this.#onHouseClick = options.onHouseClick || null; this.#onHouseHover = options.onHouseHover || null; } /** * Initialize map with empty content * @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 transformGroup.append( this.#trainTracksGroup, this.#trainStationsGroup, this.#districtsGroup, this.#housesGroup, this.#background, ); this.#svg.append(transformGroup); this.#enablePanning(this.#svg); return this.#svg; } /** * @param {number} initVx * @param {number} initVy */ #startInertia(initVx, initVy) { let lastTime = performance.now(); let currVx = initVx; let currVy = initVy; const anim = () => { const now = performance.now(); 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 speed = Math.hypot(currVx, currVy); if (speed < 0.001) return; // Stop threshold // 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; requestAnimationFrame(anim); }; requestAnimationFrame(anim); } /** * @param {number} bounce */ #clampViewBox(bounce = 0) { 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)); return { clampedX: vb.x !== oldX, clampedY: vb.y !== oldY, }; } /** * Create an SVG element * @param {SVGSVGElement} svg */ #enablePanning(svg) { let isDragging = false; let pointerId = null; let startX, startY; let lastX, lastY, lastTime; let vx = 0, vy = 0; // Velocity in SVG units per ms let startViewBox; svg.addEventListener("pointerdown", (e) => { if (e.pointerType === "touch" && e.touches?.length > 1) 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.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; // Update velocity (for inertia) if (dt > 0) { const ctm = svg.getScreenCTM().inverse(); 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 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}`, ); // Clamp to bounds 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.style.cursor = "grab"; // Start inertia if velocity sufficient const speed = Math.hypot(vx, vy); if (speed > 0.001) { // Threshold (adjust as needed) this.#startInertia(vx, vy); } }); svg.addEventListener("pointercancel", (e) => { if (e.pointerId !== pointerId) return; isDragging = false; pointerId = null; this.#svg.releasePointerCapture(e.pointerId); this.#svg.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, { attributes: { "data-id": house.id, r: 0.002, }, classes: ["house-marker"], styles: { cursor: "pointer", fill: this.#getHouseColor(house), stroke: "#333", "stroke-width": "0.001", }, }); // 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("click", (e) => { e.stopPropagation(); this.#onHouseClickCallback(circle, house.id); }); return circle; }); this.#replaceGroupContent(this.#housesGroup, houseElements); } /** * 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"], }); poly.addEventListener("mouseenter", () => { poly.style.fill = "rgba(100,150,255,0.4)"; poly.style.stroke = "#333"; poly.setAttribute("stroke-width", "0.002"); }); poly.addEventListener("mouseleave", () => { poly.style.fill = "rgba(100,150,255,0.2)"; poly.style.stroke = "#555"; 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", }, }); }); const bounds = MapEl.#calculateBounds(districts); this.#fullBounds = bounds; this.#updateViewBox(bounds); this.#replaceGroupContent(this.#districtsGroup, [...polygonElements, ...labelElements]); } /** * @param {Collection} coastline * @param {Collection} mainRoads */ 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; } }) .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; } }) .filter((x) => x !== null); this.#background.setAttribute("fill", "none"); this.#replaceGroupContent(this.#background, [...coastLinePaths, ...mainRoadPaths]); } /** * Set train infrastructure data * @param {TrainStation[]} stations * @param {TrainTracks[]} tracks */ setTrainData(stations, tracks) { const trackElements = tracks.map((track) => { return Svg.path(track.lineString, { attributes: { stroke: "#ff4444", "stroke-width": 0.001, }, classes: ["train-track"], }); }); 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", }, }); }); 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.dataset.id; const house = this.#houses.find((h) => h.id === houseId); if (house) { const color = this.#getHouseColor(house); marker.style.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.dataset.id; marker.style.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}`); } /** * 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} */ #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 "#4caf50"; } const normalized = Math.max(0, Math.min(1, (value - min) / (max - min))); 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(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})`; } 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})`; } } }