diff options
Diffstat (limited to 'app/map.js')
| -rw-r--r-- | app/map.js | 321 |
1 files changed, 159 insertions, 162 deletions
@@ -61,32 +61,19 @@ export class PanningConfig { 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; + svg; /** @type {House[]} */ #houses = []; - /** @type {string} */ - #colorParameter = ColorParameter.price; - /** @type {Function|null} */ - #onHouseClick = null; + /** @type {SVGGElement|null} */ + #housesGroup = 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 +81,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: { @@ -113,62 +100,118 @@ export class MapEl { }), ); - 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; + this.#onHouseHover = options.onHouseHover; + this.#enablePanning(this.svg); + } - this.#svg = svg; - this.#onHouseClick = options.onHouseClick || null; - this.#onHouseHover = options.onHouseHover || null; + /** + * @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 + * @param {House[]} houses + * @param {string} colorParameter * @returns {SVGSVGElement} */ - initializeMap() { + initialize(districts, coastLine, mainRoads, tracks, stations, houses, colorParameter) { + this.#houses = houses; + this.#setInitialViewBox(District.bounds(districts)); const transformGroup = Svg.g( new SvgOptions({ attributes: { transform: "scale(1, -1)" }, + children: [ + Svg.g( + new SvgOptions({ + attributes: { + "pointer-events": "none", + "stroke-width": "0.0005", + }, + children: [...MapEl.#getCoastLine(coastLine), ...MapEl.#getRoads(mainRoads)], + id: "background", + }), + ), + Svg.g( + new SvgOptions({ + attributes: { + fill: "none", + "pointer-events": "none", + stroke: "rgba(255, 68, 68, 1)", + "stroke-width": "0.001", + }, + children: MapEl.#getTracks(tracks), + id: "train-tracks", + }), + ), + 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", + }, + children: MapEl.#getStations(stations), + id: "train-stations", + }), + ), + Svg.g( + new SvgOptions({ + attributes: {}, + children: [...MapEl.#getDistricts(districts), ...MapEl.#getDistrictLabels(districts)], + id: "districts", + }), + ), + Svg.g( + new SvgOptions({ + attributes: { + "pointer-events": "visiblePainted", + r: "0.003", + stroke: "rgba(51, 51, 51, 1)", + "stroke-linecap": "butt", + "stroke-width": "0.001", + }, + children: this.getHouses(houses, colorParameter), + id: "houses", + }), + ), + ], id: "map-transform", }), ); + this.svg.append(transformGroup); - transformGroup.append( - this.#background, - this.#districtsGroup, - this.#trainTracksGroup, - this.#trainStationsGroup, - this.#housesGroup, - ); - this.#svg.append(transformGroup); - this.#enablePanning(this.#svg); - - return this.#svg; + 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 +236,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 +252,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 +277,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,20 +284,19 @@ export class MapEl { 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, + feature.geometry.simplify(30), new SvgOptions({ attributes: defaultStyle, }), ); } else if (feature.geometry instanceof MultiLineString) { return Svg.path( - new LineString(feature.geometry.coordinates.flat()), + new LineString(feature.geometry.simplify(30).coordinates.flat()), new SvgOptions({ attributes: defaultStyle, }), @@ -336,7 +379,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 +391,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,35 +404,26 @@ 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;"); }); } /** * Set houses data and render markers * @param {House[]} houses - * @param {string} [colorParameter=this.#colorParameter] + * @param {ColorParameter} colorParameter */ - setHouses(houses, colorParameter = this.#colorParameter) { - this.#houses = houses; - this.#colorParameter = colorParameter; - - const houseElements = houses.map((house) => { + getHouses(houses, colorParameter) { + return 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", + fill: MapEl.#getHouseColor(house, colorParameter), }, classes: ["house-marker"], - styles: { cursor: "pointer" }, }), ); @@ -418,7 +452,6 @@ export class MapEl { }, 200); } }); - circle.addEventListener("click", (e) => { e.stopPropagation(); if (this.#onHouseClick) { @@ -426,29 +459,26 @@ export class MapEl { 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) => { + static #getDistricts(districts) { + return districts.map((district) => { const poly = Svg.polygon( - district.polygon, + district.polygon.simplify(30), 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 +496,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,92 +517,74 @@ 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); } /** * Update house colors based on current color parameter - * @param {string} colorParameter + * @param {ColorParameter} colorParameter */ setColorParameter(colorParameter) { - this.#colorParameter = colorParameter; - - const markers = this.#housesGroup.querySelectorAll(".house-marker"); - markers.forEach((marker) => { + 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); + const color = MapEl.#getHouseColor(house, colorParameter); marker.setAttribute("fill", color); } }); @@ -578,9 +596,9 @@ export class MapEl { */ updateHouseVisibility(filteredHouseIds) { const filteredSet = new Set(filteredHouseIds); - const markers = this.#housesGroup.querySelectorAll(".house-marker"); + const markers = this.#housesGroup?.querySelectorAll(".house-marker"); - markers.forEach((marker) => { + markers?.forEach((marker) => { const houseId = marker.id; marker.setAttribute("display", filteredSet.has(houseId) ? "" : "none"); }); @@ -602,36 +620,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 {ColorParameter} 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; |
