diff options
| author | Petri Hienonen <petri.hienonen@gmail.com> | 2025-11-04 17:07:24 +0200 |
|---|---|---|
| committer | Petri Hienonen <petri.hienonen@gmail.com> | 2025-11-06 09:48:54 +0200 |
| commit | 12a937ca89898a50aefbc664acdfcc385d093bff (patch) | |
| tree | 32f9bc3ea0e88db11d24a9f9fdfcb9ab8da087f0 /app | |
| parent | a4ed99a370930b1a0c0f065906ed99c15a015fd4 (diff) | |
| download | housing-12a937ca89898a50aefbc664acdfcc385d093bff.tar.zst | |
Minor clean
Diffstat (limited to 'app')
| -rw-r--r-- | app/main.js | 12 | ||||
| -rw-r--r-- | app/map.js | 253 | ||||
| -rw-r--r-- | app/svg.js | 12 |
3 files changed, 147 insertions, 130 deletions
diff --git a/app/main.js b/app/main.js index 617b9bb..2485cb1 100644 --- a/app/main.js +++ b/app/main.js @@ -107,7 +107,6 @@ export class App { this.#stats = stats; this.#controls = controls; - // Initialize map this.#map = new MapEl({ onHouseClick: (houseId, persistent) => this.#showHouseModal(houseId, persistent), onHouseHover: (houseId, hide) => { @@ -118,8 +117,9 @@ export class App { } }, }); - mapContainer.append(this.#map.initializeMap(), stats); - this.#loadData(loading); + + mapContainer.append(this.#map.svg, stats); + this.#initialize(loading); } /** @@ -583,7 +583,7 @@ export class App { * Load data and initialize application * @param {HTMLElement} loading */ - async #loadData(loading) { + async #initialize(loading) { try { const [districts, houses, trainStations, trainTracks, coastLine, mainRoads] = await Promise.all([ @@ -603,10 +603,8 @@ export class App { this.#filtered = houses.slice(); if (this.#map) { + this.#map.initialize(districts, coastLine, mainRoads, trainTracks, trainStations); this.#map.setHouses(houses, this.#colorParameter); - this.#map.setTrainData(trainStations, trainTracks); - this.#map.setDistricts(districts); - this.#map.setMapData(coastLine, mainRoads); } // Populate district multi-select @@ -66,7 +66,7 @@ export class PanningConfig { */ export class MapEl { /** @type {SVGSVGElement} */ - #svg; + svg; /** @type {SVGGElement} */ #housesGroup; /** @type {SVGGElement} */ @@ -82,11 +82,11 @@ export class MapEl { /** @type {string} */ #colorParameter = ColorParameter.price; /** @type {Function|null} */ - #onHouseClick = null; - /** @type {Function|null} */ - #onHouseHover = null; + #onHouseClick; + /** @type {Function} */ + #onHouseHover; /** @type {number|undefined} */ - #modalTimer = undefined; + #modalTimer; /** @type {boolean} */ #persistentModal = false; /** @type {Bounds|null} */ @@ -94,10 +94,10 @@ export class MapEl { /** * @param {Object} options - * @param {Function} [options.onHouseClick] - * @param {Function} [options.onHouseHover] + * @param {Function} options.onHouseClick + * @param {Function} options.onHouseHover */ - constructor(options = {}) { + constructor(options) { const svg = Svg.svg( new SvgOptions({ attributes: { @@ -115,32 +115,59 @@ export class MapEl { this.#housesGroup = Svg.g( new SvgOptions({ + attributes: { + "pointer-events": "visiblePainted", + r: "0.003", + stroke: "rgba(51, 51, 51, 1)", + "stroke-linecap": "butt", + "stroke-width": "0.001", + }, id: "houses", }), ); this.#trainTracksGroup = Svg.g( new SvgOptions({ attributes: { + fill: "none", + "pointer-events": "none", 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.#trainStationsGroup = Svg.g( + new SvgOptions({ + attributes: { + fill: "rgba(255, 68, 68, 1)", + "pointer-events": "none", + r: "0.003", + stroke: "rgba(204, 0, 0, 1)", + "stroke-width": "0.001", + }, + id: "train-stations", + }), + ); + this.#districtsGroup = Svg.g( + new SvgOptions({ + attributes: {}, + id: "districts", + }), + ); + this.#background = Svg.g( + new SvgOptions({ + attributes: { + "pointer-events": "none", + "stroke-width": "0.0005", + }, + id: "background", + }), + ); - this.#svg = svg; - this.#onHouseClick = options.onHouseClick || null; - this.#onHouseHover = options.onHouseHover || null; - } + this.svg = svg; + this.#onHouseClick = options.onHouseClick; + this.#onHouseHover = options.onHouseHover; - /** - * Initialize map with empty content - * @returns {SVGSVGElement} - */ - initializeMap() { const transformGroup = Svg.g( new SvgOptions({ attributes: { transform: "scale(1, -1)" }, @@ -155,20 +182,60 @@ export class MapEl { this.#trainStationsGroup, this.#housesGroup, ); - this.#svg.append(transformGroup); - this.#enablePanning(this.#svg); + this.svg.append(transformGroup); + this.#enablePanning(this.svg); + } + + /** + * @param {Bounds} bounds + */ + #setInitialViewBox(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}`); + } + + /** + * Initialize map with empty content + * @param {District[]} districts + * @param {Collection} coastLine + * @param {Collection} mainRoads + * @param {TrainTracks[]} tracks + * @param {TrainStation[]} stations + * @returns {SVGSVGElement} + */ + initialize(districts, coastLine, mainRoads, tracks, stations) { + this.#setInitialViewBox(District.bounds(districts)); + this.#districtsGroup.replaceChildren( + ...MapEl.#getDistricts(districts), + ...MapEl.#getDistrictLabels(districts), + ); + + this.#trainTracksGroup.replaceChildren(...MapEl.#getTracks(tracks)); + this.#trainStationsGroup.replaceChildren(...MapEl.#getStations(stations)); - return this.#svg; + this.#background.replaceChildren( + ...MapEl.#getCoastLine(coastLine), + ...MapEl.#getRoads(mainRoads), + ); + 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]); + return this.svg; } /** * Start inertia animation for panning + * @param {SVGSVGElement} svg * @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( + svg, initVx, initVy, friction = PanningConfig.DEFAULT_FRICTION, @@ -193,11 +260,11 @@ export class MapEl { const deltaX = currVx * dt; const deltaY = currVy * dt; - const vb = this.#svg.viewBox.baseVal; + const vb = svg.viewBox.baseVal; vb.x -= deltaX; vb.y -= deltaY; - const { clampedX, clampedY } = this.#clampViewBox(); + const { clampedX, clampedY } = MapEl.#clampViewBox(svg, this.#fullBounds); if (clampedX) currVx = -currVx * PanningConfig.DEFAULT_BOUNCE_FACTOR; if (clampedY) currVy = -currVy * PanningConfig.DEFAULT_BOUNCE_FACTOR; @@ -209,17 +276,19 @@ export class MapEl { /** * Clamp viewBox to stay within bounds + * @param {SVGSVGElement} svg + * @param {Bounds|null} bounds * @returns {{clampedX: boolean, clampedY: boolean}} */ - #clampViewBox() { - if (!this.#fullBounds) return { clampedX: false, clampedY: false }; + static #clampViewBox(svg, bounds) { + if (!bounds) return { clampedX: false, clampedY: false }; - const vb = this.#svg.viewBox.baseVal; + const vb = 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); + vb.x = MapMath.clamp(vb.x, bounds.minX, bounds.maxX - vb.width); + vb.y = MapMath.clamp(vb.y, -bounds.maxY, -bounds.minY - vb.height); return { clampedX: vb.x !== oldX, @@ -232,7 +301,6 @@ export class MapEl { * @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} */ @@ -240,7 +308,6 @@ export class MapEl { const defaultStyle = { fill: "none", stroke: "rgba(0, 0, 0, 1)", - "stroke-width": "0.0005", ...style, }; @@ -336,7 +403,7 @@ export class MapEl { `${newMinX} ${newMinY} ${startViewBox.width} ${startViewBox.height}`, ); - this.#clampViewBox(); + MapEl.#clampViewBox(svg, this.#fullBounds); lastX = e.clientX; lastY = e.clientY; @@ -348,12 +415,12 @@ export class MapEl { if (e.pointerId !== pointerId) return; isDragging = false; pointerId = null; - this.#svg.releasePointerCapture(e.pointerId); - this.#svg.setAttribute("style", "cursor: grab;"); + 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); + this.#startInertia(this.svg, vx, vy); } }); @@ -361,8 +428,8 @@ export class MapEl { if (e.pointerId !== pointerId) return; isDragging = false; pointerId = null; - this.#svg.releasePointerCapture(e.pointerId); - this.#svg.setAttribute("style", "cursor: grab;"); + this.svg.releasePointerCapture(e.pointerId); + this.svg.setAttribute("style", "cursor: grab;"); }); } @@ -381,15 +448,9 @@ export class MapEl { 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", + fill: MapEl.#getHouseColor(house, colorParameter), }, classes: ["house-marker"], - styles: { cursor: "pointer" }, }), ); @@ -429,26 +490,25 @@ export class MapEl { return circle; }); - - this.#replaceGroupContent(this.#housesGroup, houseElements); + this.#housesGroup.replaceChildren(...houseElements); } /** * Set districts data and render polygons * @param {District[]} districts */ - setDistricts(districts) { - const polygonElements = districts.map((district) => { + static #getDistricts(districts) { + return districts.map((district) => { const poly = Svg.polygon( district.polygon, new SvgOptions({ attributes: { "data-id": district.name, fill: "rgba(100, 150, 255, 0.2)", + "pointer-events": "stroke", stroke: "rgba(85, 85, 85, 1)", "stroke-width": "0.001", }, - classes: ["district"], }), ); @@ -466,8 +526,14 @@ export class MapEl { return poly; }); + } - const labelElements = districts.map((district) => { + /** + * Set districts data and render polygons + * @param {District[]} districts + */ + static #getDistrictLabels(districts) { + return districts.map((district) => { const center = district.polygon.centroid(); return Svg.text( center, @@ -481,77 +547,61 @@ export class MapEl { "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 + * @param {Collection} roads */ - setMapData(coastline, mainRoads) { - const coastLinePaths = coastline.features + static #getRoads(roads) { + return roads.features .map((feature) => MapEl.#renderLineFeature(feature, { - stroke: "rgba(25, 25, 112, 1)", - strokeWidth: "0.0005", + stroke: "rgba(0, 0, 0, 1)", }), ) .filter((x) => x !== null); + } - const mainRoadPaths = mainRoads.features + /** + * @param {Collection} coastline + */ + static #getCoastLine(coastline) { + return coastline.features .map((feature) => MapEl.#renderLineFeature(feature, { - stroke: "rgba(0, 0, 0, 1)", - strokeWidth: "0.0005", + stroke: "rgba(25, 25, 112, 1)", }), ) .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) => { + static #getTracks(tracks) { + return tracks.map((track) => { return Svg.path(track.lineString, new SvgOptions({})); }); + } - this.#replaceGroupContent(this.#trainTracksGroup, trackElements); - - const stationElements = stations.map((station) => { + /** + * @param {TrainStation[]} stations + */ + static #getStations(stations) { + return 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"], + attributes: {}, }), ); }); - - this.#replaceGroupContent(this.#trainStationsGroup, stationElements); } /** @@ -566,7 +616,7 @@ export class MapEl { const houseId = marker.id; const house = this.#houses.find((h) => h.id === houseId); if (house) { - const color = this.#getHouseColor(house); + const color = MapEl.#getHouseColor(house, colorParameter); marker.setAttribute("fill", color); } }); @@ -602,36 +652,15 @@ export class MapEl { } /** - * 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 + * @param {string} colorParameter * @returns {string} */ - #getHouseColor(house) { + static #getHouseColor(house, colorParameter) { let value, min, max; - switch (this.#colorParameter) { + switch (colorParameter) { case ColorParameter.price: value = house.price; min = 0; @@ -127,7 +127,7 @@ export class Svg { static path(lineString, options = new SvgOptions()) { const element = document.createElementNS("http://www.w3.org/2000/svg", "path"); element.setAttribute("d", Svg.getPath(lineString.coordinates)); - for (const [key, value] of Object.entries({ fill: "none", ...options.attributes })) { + for (const [key, value] of Object.entries(options.attributes)) { element.setAttribute(key, value); } Object.assign(element.style, options.styles); @@ -284,14 +284,4 @@ export class Svg { element.append(...options.children.filter(Boolean)); return element; } - - /** - * Clear all children from an element - * @param {SVGElement} element - */ - static clear(element) { - while (element.firstChild) { - element.removeChild(element.firstChild); - } - } } |
