aboutsummaryrefslogtreecommitdiffstats
path: root/app/map.js
diff options
context:
space:
mode:
authorPetri Hienonen <petri.hienonen@gmail.com>2025-10-29 15:18:30 +0200
committerPetri Hienonen <petri.hienonen@gmail.com>2025-11-03 10:54:48 +0200
commitb03ee7032b2ea2d4d22ab7ec1346b7c9331cfc17 (patch)
treeefc0ce6823ab8611d9c6a0bf27ecdbd124638b73 /app/map.js
downloadhousing-b03ee7032b2ea2d4d22ab7ec1346b7c9331cfc17.tar.zst
Initial commit
Diffstat (limited to 'app/map.js')
-rw-r--r--app/map.js644
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})`;
+ }
+ }
+}