aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorPetri Hienonen <petri.hienonen@gmail.com>2025-11-04 17:07:24 +0200
committerPetri Hienonen <petri.hienonen@gmail.com>2025-11-06 09:48:54 +0200
commit12a937ca89898a50aefbc664acdfcc385d093bff (patch)
tree32f9bc3ea0e88db11d24a9f9fdfcb9ab8da087f0
parenta4ed99a370930b1a0c0f065906ed99c15a015fd4 (diff)
downloadhousing-12a937ca89898a50aefbc664acdfcc385d093bff.tar.zst
Minor clean
-rw-r--r--app/main.js12
-rw-r--r--app/map.js253
-rw-r--r--app/svg.js12
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
diff --git a/app/map.js b/app/map.js
index e4d4331..f26c548 100644
--- a/app/map.js
+++ b/app/map.js
@@ -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;
diff --git a/app/svg.js b/app/svg.js
index 24f6a3d..84d3aa6 100644
--- a/app/svg.js
+++ b/app/svg.js
@@ -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);
- }
- }
}