diff options
| author | Petri Hienonen <petri.hienonen@gmail.com> | 2025-10-29 15:18:30 +0200 |
|---|---|---|
| committer | Petri Hienonen <petri.hienonen@gmail.com> | 2025-11-03 10:54:48 +0200 |
| commit | b03ee7032b2ea2d4d22ab7ec1346b7c9331cfc17 (patch) | |
| tree | efc0ce6823ab8611d9c6a0bf27ecdbd124638b73 /app/map.js | |
| download | housing-b03ee7032b2ea2d4d22ab7ec1346b7c9331cfc17.tar.zst | |
Initial commit
Diffstat (limited to 'app/map.js')
| -rw-r--r-- | app/map.js | 644 |
1 files changed, 644 insertions, 0 deletions
diff --git a/app/map.js b/app/map.js new file mode 100644 index 0000000..65b3a5a --- /dev/null +++ b/app/map.js @@ -0,0 +1,644 @@ +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})`; + } + } +} |
