aboutsummaryrefslogtreecommitdiffstats
path: root/app/map.js
diff options
context:
space:
mode:
Diffstat (limited to 'app/map.js')
-rw-r--r--app/map.js321
1 files changed, 159 insertions, 162 deletions
diff --git a/app/map.js b/app/map.js
index e4d4331..2658df9 100644
--- a/app/map.js
+++ b/app/map.js
@@ -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;