aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.adoc23
-rw-r--r--app/dom.js63
-rw-r--r--app/geometry.js52
-rw-r--r--app/main.js68
-rw-r--r--app/map.js549
-rw-r--r--app/models.js23
-rw-r--r--app/requirements.tsv20
-rw-r--r--app/svg.js327
-rw-r--r--jsconfig.json2
-rw-r--r--server.js72
10 files changed, 640 insertions, 559 deletions
diff --git a/README.adoc b/README.adoc
index ae788c8..3893c81 100644
--- a/README.adoc
+++ b/README.adoc
@@ -1,9 +1,9 @@
-= Housing selector
-:description: Documentation for the project
+= Living space
+:description: Documentation for the Living space
:sectanchors:
:url-repo: https://git.tammi.cc/housing.git
-SVG and WGS94 https://www.w3.org/TR/SVG11/coords.html
+SVG and WGS84 https://www.w3.org/TR/SVG11/coords.html
The project consists of three parts:
@@ -11,7 +11,7 @@ The project consists of three parts:
2. CouchDB and S3
3. Scraper (Golang)
-The developments tools are listed in flake.nix and development environment can be start with command:
+The developments tools are listed in flake.nix. The development environment can be start with command:
[source,bash]
----
@@ -25,7 +25,7 @@ The application resides in app directory.
[%header,format=tsv]
|===
-include::app/requirements.tsv[]
+include::./app/requirements.tsv[]
|===
Run (the development server) with:
@@ -35,16 +35,17 @@ Run (the development server) with:
node server.js
----
-Static assets are deployed to web server.
+Static assets are deployed directly to a web server with Git.
+Later it could be possible to download the assets to CounchDB.
== Scraper runner
-Golang is used to fetch the data to CouchDB.
+Golang implementation fetches the data to CouchDB.
The runner resides in scrape directory.
[%header,format=tsv]
|===
-include::scrape/requirements.tsv[]
+include::./scrape/requirements.tsv[]
|===
The running will require some cookies to be gotten from a web browser. Run with:
@@ -66,9 +67,9 @@ go run main.go
- Make the weight calculation work.
- Fix the map zoom and initial viewport
- Add links to house details on the service. Add additional images
-- Parse more data from data source. Now only overview is parsed.
+- Parse more data from data source. Currently only overview is parsed.
- Images on modal open on click to a new window
-- Visual programming? Value function description with javascript?
+- Visual programming? Value function description with Javascript?
- Notifications to user on new houses
- Sharing via URL
- Real support for MultiLineString in geometry
@@ -81,7 +82,7 @@ https://kartta.hel.fi/avoindata/dokumentit/Aineistolista_wfs_avoindata.html
WFS Capabilities can be found from:
https://kartta.hel.fi/ws/geoserver/avoindata/wfs?version=2.0.0&request=GetCapabilities
-The data can then be downloaded with CURL.
+The data can then be downloaded with CURL. Examples below:
=== Roads
[source,bash]
diff --git a/app/dom.js b/app/dom.js
index 39d9436..5f94132 100644
--- a/app/dom.js
+++ b/app/dom.js
@@ -143,9 +143,8 @@ export class Dom {
if (onInput) {
input.addEventListener(
"input",
- /** @param {InputEvent} e */ (e) => {
- const _target = /** @type {HTMLInputElement} */ (e.target);
- onInput(e);
+ /** @param {Event} e */ (e) => {
+ onInput(/** @type {InputEvent} */ (e));
},
);
}
@@ -368,7 +367,7 @@ export class Dom {
});
const title = Dom.heading(2, {
styles: { color: "#333", fontSize: "20px", margin: "0" },
- textContent: house.location.address,
+ textContent: house.address,
});
const score = Dom.span({
styles: {
@@ -400,6 +399,12 @@ export class Dom {
{ label: "Living Area", value: `${house.property.livingArea} m²` },
{ label: "District", value: house.location.district },
{ label: "Rooms", value: house.property.rooms?.toString() ?? "N/A" },
+ { label: "Price", value: `${house.price} €` },
+ { label: "Building Type", value: house.buildingType },
+ { label: "Construction Year", value: house.constructionYear?.toString() ?? "N/A" },
+ { label: "Living Area", value: `${house.livingArea} m²` },
+ { label: "District", value: house.district },
+ { label: "Rooms", value: house.rooms?.toString() ?? "N/A" },
];
for (const { label, value } of details) {
const item = Dom.div({
@@ -423,7 +428,7 @@ export class Dom {
});
const descText = Dom.p({
styles: { color: "#333", fontSize: "14px", lineHeight: "1.4", marginTop: "5px" },
- textContent: house.property.description ?? "No description available.",
+ textContent: house.description ?? "No description available.",
});
Dom.appendChildren(descSect, [descTitle, descText]);
frag.appendChild(descSect);
@@ -553,4 +558,52 @@ export class Dom {
static replace(oldEl, newEl) {
oldEl.parentNode?.replaceChild(newEl, oldEl);
}
+
+ /**
+ * Create a weight slider
+ * @param {string} id
+ * @param {string} labelText
+ * @param {string} weightKey
+ * @param {number} initialValue
+ * @param {(key: string, value: number) => void} onChange
+ * @returns {HTMLElement}
+ */
+ static slider(id, labelText, weightKey, initialValue, onChange) {
+ const group = Dom.div({
+ styles: { display: "flex", flexDirection: "column", marginBottom: "1rem" },
+ });
+
+ const label = Dom.label({ for: id });
+ const output = Dom.span({
+ id: `${id}-value`,
+ styles: { color: "#0066cc", fontSize: "0.85rem", fontWeight: "bold", textAlign: "center" },
+ textContent: initialValue.toFixed(1),
+ });
+
+ const labelTextSpan = Dom.span({
+ styles: { fontSize: "0.85rem" },
+ textContent: labelText,
+ });
+
+ label.append(labelTextSpan, " ", output);
+
+ const slider = Dom.input({
+ attributes: { max: "1", min: "0", step: "0.1", value: initialValue.toString() },
+ id,
+ onInput: /** @param {Event} e */ (e) => {
+ const target = /** @type {HTMLInputElement} */ (e.target);
+ const val = Number(target.value);
+ output.textContent = val.toFixed(1);
+ onChange(weightKey, val);
+ },
+ styles: {
+ margin: "0.5rem 0",
+ width: "100%",
+ },
+ type: "range",
+ });
+
+ group.append(label, slider);
+ return group;
+ }
}
diff --git a/app/geometry.js b/app/geometry.js
index 421d90b..62236e0 100644
--- a/app/geometry.js
+++ b/app/geometry.js
@@ -10,7 +10,6 @@ const TOLERANCE = 1e-10;
/**
* Geographic bounds representation
- * @class
*/
export class Bounds {
/**
@@ -26,6 +25,19 @@ export class Bounds {
this.maxY = maxY;
}
+ /**
+ * Create a union of multiple Bounds objects to cover the combined area
+ * @param {Bounds[]} boundsArray - Array of Bounds instances
+ * @returns {Bounds} A new Bounds instance covering all input bounds
+ */
+ static union(boundsArray) {
+ const minX = Math.min(...boundsArray.map((b) => b.minX));
+ const minY = Math.min(...boundsArray.map((b) => b.minY));
+ const maxX = Math.max(...boundsArray.map((b) => b.maxX));
+ const maxY = Math.max(...boundsArray.map((b) => b.maxY));
+ return new Bounds(minX, minY, maxX, maxY);
+ }
+
/** @returns {number} Width in degrees */
get width() {
return this.maxX - this.minX;
@@ -121,8 +133,8 @@ export class Geometry {
/**
* Deserialize geometry from GeoJSON
- * @param {Object} geojson - GeoJSON geometry object
- * @returns {Geometry|null} Geometry instance or null
+ * @param {{type: string, coordinates?: any, [key: string]: any}} geojson
+ * @returns {Geometry|null}
*/
static fromGeoJSON(geojson) {
if (!geojson?.type) return null;
@@ -136,7 +148,7 @@ export class Geometry {
case "MultiLineString":
return MultiLineString.fromGeoJSON(geojson);
default:
- throw new Error(`Unsupported geometry type: ${geojson.type}`);
+ throw new Error(`Invalid GeoJSON object: missing required 'type' property`);
}
}
@@ -253,8 +265,8 @@ export class Geometry {
const pointsB = Geometry.#geometryToPoints(b);
let minDistance = Infinity;
+ /** @type {[Point|null, Point|null]} */
let closestPair = [null, null];
-
for (const pointA of pointsA) {
for (const pointB of pointsB) {
const dist = Point.distance(pointA, pointB);
@@ -639,7 +651,17 @@ export class LineString extends Geometry {
}
/**
+ /**
* Check if two segments intersect
+ * @param {number} p0x
+ * @param {number} p0y
+ * @param {number} p1x
+ * @param {number} p1y
+ * @param {number} p2x
+ * @param {number} p2y
+ * @param {number} p3x
+ * @param {number} p3y
+ * @returns {boolean}
*/
static #segmentsIntersect(p0x, p0y, p1x, p1y, p2x, p2y, p3x, p3y) {
const s1x = p1x - p0x;
@@ -840,7 +862,17 @@ export class Polygon extends Geometry {
* @returns {Polygon}
*/
static fromGeoJSON(geojson) {
- return new Polygon(geojson.coordinates);
+ const rings = geojson.coordinates.map((ring /*: Coordinate[] */) => {
+ if (ring.length < 1) return ring;
+ const first = ring[0];
+ const last = ring[ring.length - 1];
+ if (first[0] !== last[0] || first[1] !== last[1]) {
+ // Append first coordinate to the end if not closed
+ return [...ring, first];
+ }
+ return ring;
+ });
+ return new Polygon(rings);
}
/**
@@ -892,6 +924,10 @@ export class Feature {
* @param {Object} geojson - GeoJSON feature
* @returns {Feature|null}
*/
+ /**
+ * @param {{geometry: object, properties?: object, id?: string|number}} geojson
+ * @returns {Feature|null}
+ */
static fromGeoJSON(geojson) {
if (!geojson?.geometry) return null;
@@ -928,6 +964,10 @@ export class Collection {
* @param {Object} geojson - GeoJSON collection
* @returns {Collection}
*/
+ /**
+ * @param {{features?: any[]}} geojson
+ * @returns {Collection}
+ */
static fromGeoJSON(geojson) {
const features = (geojson.features ?? []).map(Feature.fromGeoJSON).filter(Boolean);
diff --git a/app/main.js b/app/main.js
index 01ceef1..617b9bb 100644
--- a/app/main.js
+++ b/app/main.js
@@ -325,32 +325,32 @@ export class App {
// Create weight sliders
const weightSliders = [
- App.addSlider("w-price", "Price weight", "price", weights.price, onWeightChange),
- App.addSlider(
+ Dom.slider("w-price", "Price weight", "price", weights.price, onWeightChange),
+ Dom.slider(
"w-market",
"Market distance",
"distanceMarket",
weights.distanceMarket,
onWeightChange,
),
- App.addSlider(
+ Dom.slider(
"w-school",
"School distance",
"distanceSchool",
weights.distanceSchool,
onWeightChange,
),
- App.addSlider("w-crime", "Crime rate", "crimeRate", weights.crimeRate, onWeightChange),
- App.addSlider("w-safety", "Safety index", "safety", weights.safety, onWeightChange),
- App.addSlider("w-students", "S2 students", "s2Students", weights.s2Students, onWeightChange),
- App.addSlider(
+ Dom.slider("w-crime", "Crime rate", "crimeRate", weights.crimeRate, onWeightChange),
+ Dom.slider("w-safety", "Safety index", "safety", weights.safety, onWeightChange),
+ Dom.slider("w-students", "S2 students", "s2Students", weights.s2Students, onWeightChange),
+ Dom.slider(
"w-railway",
"Railway distance",
"distanceRailway",
weights.distanceRailway,
onWeightChange,
),
- App.addSlider(
+ Dom.slider(
"w-year",
"Construction year",
"constructionYear",
@@ -405,54 +405,6 @@ export class App {
}
/**
- * Create a weight slider
- * @param {string} id
- * @param {string} labelText
- * @param {string} weightKey
- * @param {number} initialValue
- * @param {(key: string, value: number) => void} onChange
- * @returns {HTMLElement}
- */
- static addSlider(id, labelText, weightKey, initialValue, onChange) {
- const group = Dom.div({
- styles: { display: "flex", flexDirection: "column", marginBottom: "1rem" },
- });
-
- const label = Dom.label({ for: id });
- const output = Dom.span({
- id: `${id}-value`,
- styles: { color: "#0066cc", fontSize: "0.85rem", fontWeight: "bold", textAlign: "center" },
- textContent: initialValue.toFixed(1),
- });
-
- const labelTextSpan = Dom.span({
- styles: { fontSize: "0.85rem" },
- textContent: labelText,
- });
-
- label.append(labelTextSpan, " ", output);
-
- const slider = Dom.input({
- attributes: { max: "1", min: "0", step: "0.1", value: initialValue.toString() },
- id,
- onInput: /** @param {Event} e */ (e) => {
- const target = /** @type {HTMLInputElement} */ (e.target);
- const val = Number(target.value);
- output.textContent = val.toFixed(1);
- onChange(weightKey, val);
- },
- styles: {
- margin: "0.5rem 0",
- width: "100%",
- },
- type: "range",
- });
-
- group.append(label, slider);
- return group;
- }
-
- /**
* Show modal with house details
* @param {string} houseId
* @param {boolean} persistent
@@ -651,9 +603,9 @@ export class App {
this.#filtered = houses.slice();
if (this.#map) {
- this.#map.setDistricts(districts);
- this.#map.setTrainData(trainStations, trainTracks);
this.#map.setHouses(houses, this.#colorParameter);
+ this.#map.setTrainData(trainStations, trainTracks);
+ this.#map.setDistricts(districts);
this.#map.setMapData(coastLine, mainRoads);
}
diff --git a/app/map.js b/app/map.js
index 65b3a5a..e4d4331 100644
--- a/app/map.js
+++ b/app/map.js
@@ -1,6 +1,6 @@
-import { Bounds, Collection, LineString, MultiLineString, Point, Polygon } from "geom";
+import { Bounds, Collection, Feature, LineString, MultiLineString, Point } from "geom";
import { District, House, TrainStation, TrainTracks } from "models";
-import { Svg } from "svg";
+import { Svg, SvgOptions } from "svg";
/**
* Color parameters for house markers
@@ -14,6 +14,54 @@ export const ColorParameter = {
};
/**
+ * Math utility functions
+ */
+export class MapMath {
+ /**
+ * Clamp a value between min and max
+ * @param {number} value
+ * @param {number} min
+ * @param {number} max
+ * @returns {number}
+ */
+ static clamp(value, min, max) {
+ return Math.max(min, Math.min(value, max));
+ }
+
+ /**
+ * Calculate linear interpolation between two values
+ * @param {number} a
+ * @param {number} b
+ * @param {number} t
+ * @returns {number}
+ */
+ static lerp(a, b, t) {
+ return a + (b - a) * t;
+ }
+
+ /**
+ * Normalize a value from one range to another
+ * @param {number} value
+ * @param {number} min
+ * @param {number} max
+ * @returns {number}
+ */
+ static normalize(value, min, max) {
+ return Math.max(0, Math.min(1, (value - min) / (max - min)));
+ }
+}
+
+/**
+ * Panning and inertia configuration
+ */
+export class PanningConfig {
+ static DEFAULT_FRICTION = 0.995;
+ static DEFAULT_SPEED_THRESHOLD = 0.001;
+ static DEFAULT_BOUNCE_FACTOR = 0.5;
+ static DEFAULT_VIEWBOX_SCALE = 1;
+}
+
+/**
* Map component for rendering houses, districts, and train infrastructure
*/
export class MapEl {
@@ -37,8 +85,8 @@ export class MapEl {
#onHouseClick = null;
/** @type {Function|null} */
#onHouseHover = null;
- /** @type {number|null} */
- #modalTimer = null;
+ /** @type {number|undefined} */
+ #modalTimer = undefined;
/** @type {boolean} */
#persistentModal = false;
/** @type {Bounds|null} */
@@ -50,28 +98,38 @@ export class MapEl {
* @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",
- });
+ const svg = Svg.svg(
+ new SvgOptions({
+ attributes: {
+ preserveAspectRatio: "xMidYMid meet",
+ viewBox: "24 60 2 2", // longitude 24-26, latitude 60-62
+ },
+ styles: {
+ 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.#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 || null;
@@ -83,24 +141,19 @@ export class MapEl {
* @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
+ const transformGroup = Svg.g(
+ new SvgOptions({
+ attributes: { transform: "scale(1, -1)" },
+ id: "map-transform",
+ }),
+ );
transformGroup.append(
+ this.#background,
+ this.#districtsGroup,
this.#trainTracksGroup,
this.#trainStationsGroup,
- this.#districtsGroup,
this.#housesGroup,
- this.#background,
);
this.#svg.append(transformGroup);
this.#enablePanning(this.#svg);
@@ -109,10 +162,18 @@ export class MapEl {
}
/**
- * @param {number} initVx
- * @param {number} initVy
+ * Start inertia animation for panning
+ * @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(initVx, initVy) {
+ #startInertia(
+ initVx,
+ initVy,
+ friction = PanningConfig.DEFAULT_FRICTION,
+ speedThreshold = PanningConfig.DEFAULT_SPEED_THRESHOLD,
+ ) {
let lastTime = performance.now();
let currVx = initVx;
let currVy = initVy;
@@ -122,27 +183,23 @@ export class MapEl {
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 actualFriction = friction ** dt;
+ currVx *= actualFriction;
+ currVy *= actualFriction;
const speed = Math.hypot(currVx, currVy);
- if (speed < 0.001) return; // Stop threshold
+ if (speed < speedThreshold) return;
- // 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;
+ if (clampedX) currVx = -currVx * PanningConfig.DEFAULT_BOUNCE_FACTOR;
+ if (clampedY) currVy = -currVy * PanningConfig.DEFAULT_BOUNCE_FACTOR;
requestAnimationFrame(anim);
};
@@ -151,17 +208,18 @@ export class MapEl {
}
/**
- * @param {number} bounce
+ * Clamp viewBox to stay within bounds
+ * @returns {{clampedX: boolean, clampedY: boolean}}
*/
- #clampViewBox(bounce = 0) {
+ #clampViewBox() {
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));
+ 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);
return {
clampedX: vb.x !== oldX,
@@ -170,20 +228,66 @@ export class MapEl {
}
/**
+ * Render a LineString or MultiLineString feature as SVG path
+ * @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}
+ */
+ static #renderLineFeature(feature, style = {}) {
+ 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,
+ new SvgOptions({
+ attributes: defaultStyle,
+ }),
+ );
+ } else if (feature.geometry instanceof MultiLineString) {
+ return Svg.path(
+ new LineString(feature.geometry.coordinates.flat()),
+ new SvgOptions({
+ attributes: defaultStyle,
+ }),
+ );
+ }
+ return null;
+ }
+
+ /**
* Create an SVG element
* @param {SVGSVGElement} svg
*/
#enablePanning(svg) {
let isDragging = false;
+ /** @type {number|null} */
let pointerId = null;
- let startX, startY;
- let lastX, lastY, lastTime;
+ /** @type {number} */
+ let startX;
+ /** @type {number} */
+ let startY;
+ /** @type {number} */
+ let lastX;
+ /** @type {number} */
+ let lastY;
+ /** @type {number} */
+ let lastTime;
+
let vx = 0,
- vy = 0; // Velocity in SVG units per ms
+ vy = 0;
+ /** @type {SVGRect} */
let startViewBox;
svg.addEventListener("pointerdown", (e) => {
- if (e.pointerType === "touch" && e.touches?.length > 1) return;
+ if (e.pointerType === "touch") return;
isDragging = true;
pointerId = e.pointerId;
svg.setPointerCapture(pointerId);
@@ -192,7 +296,7 @@ export class MapEl {
lastTime = performance.now();
vx = vy = 0;
startViewBox = svg.viewBox.baseVal;
- svg.style.cursor = "grabbing";
+ svg.setAttribute("style", "cursor: grabbing;");
e.preventDefault();
});
@@ -203,19 +307,25 @@ export class MapEl {
const dx = e.clientX - lastX;
const dy = e.clientY - lastY;
- // Update velocity (for inertia)
if (dt > 0) {
- const ctm = svg.getScreenCTM().inverse();
+ const ctm = svg.getScreenCTM()?.inverse();
+ if (ctm === undefined) {
+ throw new Error("Unexpected");
+ }
+
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 ctm = svg.getScreenCTM()?.inverse();
+ if (ctm === undefined) {
+ throw new Error("Unexpected");
+ }
+
const svgTotalDx = totalDx * ctm.a + totalDy * ctm.c;
const svgTotalDy = totalDx * ctm.b + totalDy * ctm.d;
@@ -226,7 +336,6 @@ export class MapEl {
`${newMinX} ${newMinY} ${startViewBox.width} ${startViewBox.height}`,
);
- // Clamp to bounds
this.#clampViewBox();
lastX = e.clientX;
@@ -240,12 +349,10 @@ export class MapEl {
isDragging = false;
pointerId = null;
this.#svg.releasePointerCapture(e.pointerId);
- this.#svg.style.cursor = "grab";
+ this.#svg.setAttribute("style", "cursor: grab;");
- // Start inertia if velocity sufficient
const speed = Math.hypot(vx, vy);
- if (speed > 0.001) {
- // Threshold (adjust as needed)
+ if (speed > PanningConfig.DEFAULT_SPEED_THRESHOLD) {
this.#startInertia(vx, vy);
}
});
@@ -255,7 +362,7 @@ export class MapEl {
isDragging = false;
pointerId = null;
this.#svg.releasePointerCapture(e.pointerId);
- this.#svg.style.cursor = "grab";
+ this.#svg.setAttribute("style", "cursor: grab;");
});
}
@@ -269,31 +376,55 @@ export class MapEl {
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",
- },
- });
+ 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",
+ },
+ classes: ["house-marker"],
+ styles: { cursor: "pointer" },
+ }),
+ );
- // 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("mouseenter", () => {
+ circle.setAttribute("r", "0.005");
+ clearTimeout(this.#modalTimer);
+ if (this.#onHouseHover) {
+ this.#onHouseHover(house.id, false);
+ }
+ });
+
+ circle.addEventListener("mouseleave", () => {
+ circle.setAttribute("r", "0.003");
+ circle.setAttribute("stroke", "rgba(51, 51, 51, 1)");
+ circle.setAttribute("stroke-width", "0.001");
+
+ if (!this.#persistentModal && this.#onHouseHover) {
+ this.#modalTimer = setTimeout(() => {
+ if (this.#onHouseHover) {
+ this.#onHouseHover(house.id, true);
+ }
+ }, 200);
+ }
+ });
+
circle.addEventListener("click", (e) => {
e.stopPropagation();
- this.#onHouseClickCallback(circle, house.id);
+ if (this.#onHouseClick) {
+ this.#onHouseClick(house.id, true);
+ this.#persistentModal = true;
+ }
});
return circle;
@@ -303,75 +434,59 @@ export class MapEl {
}
/**
- * 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"],
- });
+ const poly = Svg.polygon(
+ district.polygon,
+ new SvgOptions({
+ attributes: {
+ "data-id": district.name,
+ fill: "rgba(100, 150, 255, 0.2)",
+ stroke: "rgba(85, 85, 85, 1)",
+ "stroke-width": "0.001",
+ },
+ classes: ["district"],
+ }),
+ );
poly.addEventListener("mouseenter", () => {
- poly.style.fill = "rgba(100,150,255,0.4)";
- poly.style.stroke = "#333";
+ poly.setAttribute("fill", "rgba(100, 150, 255, 0.4)");
+ poly.setAttribute("stroke", "rgba(51, 51, 51, 1)");
poly.setAttribute("stroke-width", "0.002");
});
poly.addEventListener("mouseleave", () => {
- poly.style.fill = "rgba(100,150,255,0.2)";
- poly.style.stroke = "#555";
+ poly.setAttribute("fill", "rgba(100, 150, 255, 0.2)");
+ poly.setAttribute("stroke", "rgba(85, 85, 85, 1)");
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",
- },
- });
+ return Svg.text(
+ center,
+ district.name,
+ new SvgOptions({
+ attributes: {
+ "data-id": district.name,
+ "dominant-baseline": "middle",
+ "font-size": "0.005",
+ "pointer-events": "none",
+ "text-anchor": "middle",
+ transform: `translate(${center.lng}, ${center.lat}) scale(1, -1) translate(${-center.lng}, ${-center.lat})`,
+ },
+ classes: ["district-label"],
+ }),
+ );
});
- const bounds = MapEl.#calculateBounds(districts);
- this.#fullBounds = bounds;
+ const bounds = District.bounds(districts);
this.#updateViewBox(bounds);
this.#replaceGroupContent(this.#districtsGroup, [...polygonElements, ...labelElements]);
}
@@ -382,51 +497,28 @@ export class MapEl {
*/
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;
- }
- })
+ .map((feature) =>
+ MapEl.#renderLineFeature(feature, {
+ stroke: "rgba(25, 25, 112, 1)",
+ strokeWidth: "0.0005",
+ }),
+ )
.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;
- }
- })
+ .map((feature) =>
+ MapEl.#renderLineFeature(feature, {
+ stroke: "rgba(0, 0, 0, 1)",
+ strokeWidth: "0.0005",
+ }),
+ )
.filter((x) => x !== null);
- this.#background.setAttribute("fill", "none");
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]);
}
/**
@@ -436,33 +528,27 @@ export class MapEl {
*/
setTrainData(stations, tracks) {
const trackElements = tracks.map((track) => {
- return Svg.path(track.lineString, {
- attributes: {
- stroke: "#ff4444",
- "stroke-width": 0.001,
- },
- classes: ["train-track"],
- });
+ return Svg.path(track.lineString, new SvgOptions({}));
});
- 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",
- },
- });
+ 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"],
+ }),
+ );
});
this.#replaceGroupContent(this.#trainStationsGroup, stationElements);
@@ -477,11 +563,11 @@ export class MapEl {
const markers = this.#housesGroup.querySelectorAll(".house-marker");
markers.forEach((marker) => {
- const houseId = marker.dataset.id;
+ const houseId = marker.id;
const house = this.#houses.find((h) => h.id === houseId);
if (house) {
const color = this.#getHouseColor(house);
- marker.style.fill = color;
+ marker.setAttribute("fill", color);
}
});
}
@@ -495,8 +581,8 @@ export class MapEl {
const markers = this.#housesGroup.querySelectorAll(".house-marker");
markers.forEach((marker) => {
- const houseId = marker.dataset.id;
- marker.style.display = filteredSet.has(houseId) ? "" : "none";
+ const houseId = marker.id;
+ marker.setAttribute("display", filteredSet.has(houseId) ? "" : "none");
});
}
@@ -538,53 +624,6 @@ export class MapEl {
}
/**
- * 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}
@@ -614,10 +653,10 @@ export class MapEl {
max = 200;
break;
default:
- return "#4caf50";
+ return "rgba(76, 175, 80, 1)";
}
- const normalized = Math.max(0, Math.min(1, (value - min) / (max - min)));
+ const normalized = MapMath.normalize(value, min, max);
return MapEl.#gradientColor(normalized);
}
@@ -629,16 +668,16 @@ export class MapEl {
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})`;
+ const r = Math.round(MapMath.lerp(42, 87, t));
+ const g = Math.round(MapMath.lerp(123, 199, t));
+ const b = Math.round(MapMath.lerp(155, 133, t));
+ return `rgba(${r}, ${g}, ${b}, 1)`;
} 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})`;
+ const r = Math.round(MapMath.lerp(87, 237, t));
+ const g = Math.round(MapMath.lerp(199, 221, t));
+ const b = Math.round(MapMath.lerp(133, 83, t));
+ return `rgba(${r}, ${g}, ${b}, 1)`;
}
}
}
diff --git a/app/models.js b/app/models.js
index 24dd5a8..d052f1c 100644
--- a/app/models.js
+++ b/app/models.js
@@ -1,4 +1,4 @@
-import { Collection, Feature, Geometry, LineString, Point, Polygon } from "geom";
+import { Bounds, Collection, Feature, LineString, Point, Polygon } from "geom";
/** @typedef {{ lat: number, lng: number }} GeoPointJson */
/** @typedef {{ date: string, price: number }} PriceUpdateJson */
@@ -172,7 +172,7 @@ export class District {
* @returns {District}
*/
static fromFeature(feature) {
- const name = feature.properties?.nimi_fi;
+ const name = "nimi_fi" in feature.properties ? feature.properties.nimi_fi : "";
const geometry = feature.geometry;
if (name === undefined || !(geometry instanceof Polygon)) {
@@ -183,6 +183,24 @@ export class District {
}
/**
+ * Set houses data and render markers
+ * @param {District[]} districts
+ */
+ static bounds(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;
+ }
+
+ /**
* Convert Collection to District[]
* @param {Collection} collection
* @returns {District[]}
@@ -400,6 +418,7 @@ export class House {
*/
static getBuildingType(type) {
// Basic mapping - can be expanded based on actual type values
+ /** @type {{[key: number]: string}} */
const typeMap = {
100: "House",
200: "Apartment",
diff --git a/app/requirements.tsv b/app/requirements.tsv
index 173a43f..12cc95a 100644
--- a/app/requirements.tsv
+++ b/app/requirements.tsv
@@ -1,10 +1,11 @@
ID Category Requirement
-ARCH-1 Architecture Three-file ES6 module structure: app.js, dom.js, models.js map.js geometry.js
-ARCH-2 Architecture Dynamic styling with no external CSS
+ARCH-1 Architecture Five-file ES6 module structure: main.js, dom.js, models.js map.js geometry.js
+ARCH-2 Architecture Dynamic styling with no external CSS (HTMLElement style property). RGBA coloring.
ARCH-3 Architecture Modern javascript with import maps for clean module resolution
ARCH-4 Architecture Comprehensive JSDoc type definitions with TSC validationn
-ARCH-5 Architecture Direct use of SVG and DOM interfaces
-ARCH-6 Architecture Static, private, classes, modules, minimal state, function chaining, null safe
+ARCH-5 Architecture Functional declarative interface around browsers native SVG (svg.js) and DOM (dom.js).
+ARCH-6 Architecture Code style: Prefer static, private, Classes, modules, minimal state, function chaining, modern null safety.
+ARCH-7 Architecture Less program lines and generic approach is always preferred over "Ad hoc" patching.
UI-1 UI Single view layout with map and control panel
UI-2 UI Native SVG map with Helsinki district boundaries
UI-3 UI Color-coded house markers based on scoring
@@ -12,12 +13,13 @@ UI-4 UI Native dialog elements for house details
UI-5 UI Toast notifications for error handling
UI-6 UI Weight sliders for scoring parameters
UI-7 UI Filter controls for price, year, area and first appeared
-DATA-1 Data Main map datasets from local geojson files
-DATA-2 Data Couchbase API integration for house data
-DATA-3 Data Class-based data models with fromJson()
-DATA-4 Data Scoring engine with weighted criteria
-PERF-1 No DOM selectors but use of direct layout handles
+DATA-1 Data Models translate json data into application structure.
+DATA-2 Data Couchbase API integrates for house data and enables querying if it is ever needed.
+DATA-3 Data Scoring engine with weighted criteria
+PERF-1 No DOM selectors but use of direct layout handles (we know each element).
+PERF-2 Changes to DOM are performed in patches. All changes are collected and elements replaces at once.
ERROR-1 Error Handling Meaningfull debug information
+ERROR-2 Error Handling Errors should be raised when the program has ended up in a state that it would not be in.
MAP-1 Direct WGS84 coordinates with moving view space
MAP-2 Map House markers with hover and click interactions
MAP-3 Map Responsive map that handles window resize, zoom and pan
diff --git a/app/svg.js b/app/svg.js
index 61d3717..24f6a3d 100644
--- a/app/svg.js
+++ b/app/svg.js
@@ -1,27 +1,55 @@
import { LineString, Point, Polygon } from "geom";
/**
- * @typedef {Object} SvgOptions
- * @property {Record<string, string|number>} [attributes] - SVG attributes
- * @property {Record<string, string>} [styles] - CSS styles
- * @property {string} [id] - Element ID
- * @property {string[]} [classes] - CSS classes
- * @property {SVGElement[]} [children] - Child elements
+ * Class representing options for SVG elements
+ * @property {Record<string, string>} attributes - SVG attributes
+ * @property {Record<string, string>} styles - CSS styles
+ * @property {string} id - Element ID
+ * @property {string[]} classes - CSS classes
+ * @property {SVGElement[]} children - Child elements
*/
+export class SvgOptions {
+ attributes;
+ styles;
+ id;
+ classes;
+ children;
+
+ /**
+ * @param {Object} [options]
+ * @param {Record<string, string>} [options.attributes] - SVG attributes
+ * @param {Record<string, string>} [options.styles] - CSS styles
+ * @param {string} [options.id] - Element ID
+ * @param {string[]} [options.classes] - CSS classes
+ * @param {SVGElement[]} [options.children] - Child elements
+ */
+ constructor({ attributes = {}, styles = {}, id = "", classes = [], children = [] } = {}) {
+ this.attributes = attributes;
+ this.styles = styles;
+ this.id = id;
+ this.classes = classes;
+ this.children = children;
+ }
+}
export class Svg {
/**
* Create an SVG element
- * @param {SvgOptions} [options={}]
+ * @param {SvgOptions} [options=new SvgOptions()]
* @returns {SVGSVGElement}
*/
- static svg(options = {}) {
+ static svg(options = new SvgOptions()) {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
- const defaultAttributes = {
+ for (const [key, value] of Object.entries({
preserveAspectRatio: "xMidYMid slice",
...options.attributes,
- };
- Svg.#applyOptions(svg, { ...options, attributes: defaultAttributes });
+ })) {
+ svg.setAttribute(key, value);
+ }
+ Object.assign(svg.style, options.styles);
+ if (options.id) svg.id = options.id;
+ if (options.classes.length) svg.classList.add(...options.classes.filter(Boolean));
+ svg.append(...options.children.filter(Boolean));
return svg;
}
@@ -38,96 +66,97 @@ export class Svg {
/**
* Create a group element
- * @param {SvgOptions} [options={}]
+ * @param {SvgOptions} [options=new SvgOptions()]
* @returns {SVGGElement}
*/
- static g(options = {}) {
+ static g(options = new SvgOptions()) {
const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
- Svg.#applyOptions(g, options);
+ for (const [key, value] of Object.entries(options.attributes)) {
+ g.setAttribute(key, value);
+ }
+ Object.assign(g.style, options.styles);
+ if (options.id) g.id = options.id;
+ if (options.classes.length) g.classList.add(...options.classes.filter(Boolean));
+ g.append(...options.children.filter(Boolean));
return g;
}
/**
* Create a polygon from Polygon geometry
* @param {Polygon} polygon
- * @param {SvgOptions} [options={}]
+ * @param {SvgOptions} [options=new SvgOptions()]
* @returns {SVGPolygonElement}
*/
- static polygon(polygon, options = {}) {
+ static polygon(polygon, options = new SvgOptions()) {
const element = document.createElementNS("http://www.w3.org/2000/svg", "polygon");
-
- // Convert polygon rings to SVG points string
- const exteriorRing = polygon.getExterior();
- const points = exteriorRing.map(([x, y]) => `${x},${y}`).join(" ");
-
- const defaultAttributes = {
- fill: "rgba(100,150,255,0.2)",
- points,
- stroke: "#555",
- "stroke-width": 0.001,
- ...options.attributes,
- };
-
- Svg.#applyOptions(element, { ...options, attributes: defaultAttributes });
+ const points = polygon
+ .getExterior()
+ .map(([x, y]) => `${x},${y}`)
+ .join(" ");
+ element.setAttribute("points", points);
+ for (const [key, value] of Object.entries(options.attributes)) {
+ element.setAttribute(key, value);
+ }
+ Object.assign(element.style, options.styles);
+ if (options.id) element.id = options.id;
+ if (options.classes.length) element.classList.add(...options.classes.filter(Boolean));
+ element.append(...options.children.filter(Boolean));
return element;
}
/**
* Create a path from LineString geometry
+ * @param {Array<[number, number]>} coords
+ */
+ static getPath(coords) {
+ const [startLng, startLat] = coords[0];
+ let pathData = `M ${startLng},${startLat}`;
+ for (let i = 1; i < coords.length; i++) {
+ const [lng, lat] = coords[i];
+ pathData += ` L ${lng},${lat}`;
+ }
+ return pathData;
+ }
+
+ /**
+ * Create a path from LineString geometry
* @param {LineString} lineString
- * @param {SvgOptions} [options={}]
+ * @param {SvgOptions} [options=new SvgOptions()]
* @returns {SVGPathElement}
*/
- static path(lineString, options = {}) {
+ static path(lineString, options = new SvgOptions()) {
const element = document.createElementNS("http://www.w3.org/2000/svg", "path");
-
- // Convert LineString coordinates to SVG path data
- const coords = lineString.coordinates;
- let pathData = "";
-
- if (coords.length > 0) {
- const [startLng, startLat] = coords[0];
- pathData = `M ${startLng},${startLat}`;
-
- for (let i = 1; i < coords.length; i++) {
- const [lng, lat] = coords[i];
- pathData += ` L ${lng},${lat}`;
- }
+ element.setAttribute("d", Svg.getPath(lineString.coordinates));
+ for (const [key, value] of Object.entries({ fill: "none", ...options.attributes })) {
+ element.setAttribute(key, value);
}
-
- const defaultAttributes = {
- d: pathData,
- fill: "none",
- stroke: "#000",
- "stroke-linecap": "round",
- "stroke-linejoin": "round",
- "stroke-width": 0.001,
- ...options.attributes,
- };
-
- Svg.#applyOptions(element, { ...options, attributes: defaultAttributes });
+ Object.assign(element.style, options.styles);
+ if (options.id) element.id = options.id;
+ if (options.classes.length) element.classList.add(...options.classes.filter(Boolean));
+ element.append(...options.children.filter(Boolean));
return element;
}
/**
* Create a circle from Point geometry
* @param {Point} point
- * @param {SvgOptions} [options={}]
+ * @param {SvgOptions} [options=new SvgOptions()]
* @returns {SVGCircleElement}
*/
- static circle(point, options = {}) {
+ static circle(point, options = new SvgOptions()) {
const element = document.createElementNS("http://www.w3.org/2000/svg", "circle");
- const defaultAttributes = {
- cx: point.lng,
- cy: point.lat,
- fill: "#4caf50",
- r: 0.002,
- stroke: "#333",
- "stroke-width": 0.001,
+ for (const [key, value] of Object.entries({
+ cx: String(point.lng),
+ cy: String(point.lat),
+ r: "0.002",
...options.attributes,
- };
-
- Svg.#applyOptions(element, { ...options, attributes: defaultAttributes });
+ })) {
+ element.setAttribute(key, value);
+ }
+ Object.assign(element.style, options.styles);
+ if (options.id) element.id = options.id;
+ if (options.classes.length) element.classList.add(...options.classes.filter(Boolean));
+ element.append(...options.children.filter(Boolean));
return element;
}
@@ -136,21 +165,24 @@ export class Svg {
* @param {Point} point
* @param {number} width - Rectangle width
* @param {number} height - Rectangle height
- * @param {SvgOptions} [options={}]
+ * @param {SvgOptions} [options=new SvgOptions()]
* @returns {SVGRectElement}
*/
- static rect(point, width, height, options = {}) {
+ static rect(point, width, height, options = new SvgOptions()) {
const element = document.createElementNS("http://www.w3.org/2000/svg", "rect");
-
- const defaultAttributes = {
- height,
- width,
- x: point.lng,
- y: point.lat,
+ for (const [key, value] of Object.entries({
+ height: String(height),
+ width: String(width),
+ x: String(point.lng),
+ y: String(point.lat),
...options.attributes,
- };
-
- Svg.#applyOptions(element, { ...options, attributes: defaultAttributes });
+ })) {
+ element.setAttribute(key, value);
+ }
+ Object.assign(element.style, options.styles);
+ if (options.id) element.id = options.id;
+ if (options.classes.length) element.classList.add(...options.classes.filter(Boolean));
+ element.append(...options.children.filter(Boolean));
return element;
}
@@ -158,32 +190,23 @@ export class Svg {
* Create a text element
* @param {Point} point - point
* @param {string} text - Text content
- * @param {SvgOptions} [options={}]
+ * @param {SvgOptions} [options=new SvgOptions()]
* @returns {SVGTextElement}
*/
- static text(point, text, options = {}) {
+ static text(point, text, options = new SvgOptions()) {
const element = document.createElementNS("http://www.w3.org/2000/svg", "text");
- const defaultAttributes = {
- x: point.lng,
- y: point.lat,
- ...options.attributes,
- };
-
- const defaultStyles = {
- dominantBaseline: "middle",
- fill: "#333",
- fontSize: "0.005",
- pointerEvents: "none",
- textAnchor: "middle",
- ...options.styles,
- };
-
element.textContent = text;
- Svg.#applyOptions(element, {
- ...options,
- attributes: defaultAttributes,
- styles: defaultStyles,
- });
+ for (const [key, value] of Object.entries({
+ x: String(point.lng),
+ y: String(point.lat),
+ ...options.attributes,
+ })) {
+ element.setAttribute(key, value);
+ }
+ Object.assign(element.style, options.styles);
+ if (options.id) element.id = options.id;
+ if (options.classes.length) element.classList.add(...options.classes.filter(Boolean));
+ element.append(...options.children.filter(Boolean));
return element;
}
@@ -191,105 +214,75 @@ export class Svg {
* Create a circle element with explicit coordinates
* @param {Point} point - Center X coordinate
* @param {number} r - Radius
- * @param {SvgOptions} [options={}]
+ * @param {SvgOptions} [options=new SvgOptions()]
* @returns {SVGCircleElement}
*/
- static circleXY(point, r, options = {}) {
+ static circleXY(point, r, options = new SvgOptions()) {
const element = document.createElementNS("http://www.w3.org/2000/svg", "circle");
-
- const defaultAttributes = {
- cx: point.lng,
- cy: point.lat,
+ for (const [key, value] of Object.entries({
+ cx: String(point.lng),
+ cy: String(point.lat),
fill: "#4caf50",
- r,
+ r: String(r),
stroke: "#333",
- "stroke-width": 0.001,
+ "stroke-width": "0.001",
...options.attributes,
- };
-
- Svg.#applyOptions(element, { ...options, attributes: defaultAttributes });
+ })) {
+ element.setAttribute(key, value);
+ }
+ Object.assign(element.style, options.styles);
+ if (options.id) element.id = options.id;
+ if (options.classes.length) element.classList.add(...options.classes.filter(Boolean));
+ element.append(...options.children.filter(Boolean));
return element;
}
/**
* Create a path from raw path data
* @param {string} pathData - SVG path data string
- * @param {SvgOptions} [options={}]
+ * @param {SvgOptions} [options=new SvgOptions()]
* @returns {SVGPathElement}
*/
- static pathData(pathData, options = {}) {
+ static pathData(pathData, options = new SvgOptions()) {
const element = document.createElementNS("http://www.w3.org/2000/svg", "path");
-
- const defaultAttributes = {
+ for (const [key, value] of Object.entries({
d: pathData,
fill: "none",
stroke: "#000",
"stroke-linecap": "round",
"stroke-linejoin": "round",
- "stroke-width": 0.001,
+ "stroke-width": "0.001",
...options.attributes,
- };
-
- Svg.#applyOptions(element, { ...options, attributes: defaultAttributes });
+ })) {
+ element.setAttribute(key, value);
+ }
+ Object.assign(element.style, options.styles);
+ if (options.id) element.id = options.id;
+ if (options.classes.length) element.classList.add(...options.classes.filter(Boolean));
+ element.append(...options.children.filter(Boolean));
return element;
}
/**
* Create a polygon from points array
* @param {Array<[number, number]>} points - Array of [x,y] points
- * @param {SvgOptions} [options={}]
+ * @param {SvgOptions} [options=new SvgOptions()]
* @returns {SVGPolygonElement}
*/
- static polygonPoints(points, options = {}) {
+ static polygonPoints(points, options = new SvgOptions()) {
const element = document.createElementNS("http://www.w3.org/2000/svg", "polygon");
-
const pointsString = points.map(([x, y]) => `${x},${y}`).join(" ");
-
- const defaultAttributes = {
- fill: "rgba(100,150,255,0.2)",
+ for (const [key, value] of Object.entries({
points: pointsString,
- stroke: "#555",
- "stroke-width": 0.001,
...options.attributes,
- };
-
- Svg.#applyOptions(element, { ...options, attributes: defaultAttributes });
- return element;
- }
-
- /**
- * Apply options to SVG element
- * @param {SVGElement} element
- * @param {SvgOptions} options
- */
- static #applyOptions(element, options = {}) {
- const { attributes = {}, styles = {}, id = "", classes = [], children = [] } = options;
-
- // Set attributes
- for (const [key, value] of Object.entries(attributes)) {
- if (value != null) {
- element.setAttribute(key, value.toString());
- }
- }
-
- // Set styles
- for (const [property, value] of Object.entries(styles)) {
- if (value != null) {
- element.style[property] = value;
- }
- }
-
- // Set ID and classes
- if (id) element.id = id;
- if (classes.length > 0) {
- element.classList.add(...classes.filter(Boolean));
- }
-
- for (const child of children) {
- if (child) {
- element.appendChild(child);
- }
+ })) {
+ element.setAttribute(key, value);
}
+ Object.assign(element.style, options.styles);
+ if (options.id) element.id = options.id;
+ if (options.classes.length) element.classList.add(...options.classes.filter(Boolean));
+ element.append(...options.children.filter(Boolean));
+ return element;
}
/**
diff --git a/jsconfig.json b/jsconfig.json
index dbb9809..cd0a987 100644
--- a/jsconfig.json
+++ b/jsconfig.json
@@ -28,7 +28,7 @@
"target": "esnext"
},
"html.format.enable": false,
- "include": ["app/*.js", "server.js"],
+ "include": ["app/*.js"],
"javascript.format.enable": false,
"json.format.enable": false,
"typescript.format.enable": false
diff --git a/server.js b/server.js
index 2af5649..090d122 100644
--- a/server.js
+++ b/server.js
@@ -2,7 +2,6 @@ import { readFileSync, statSync } from "node:fs";
import { createSecureServer, constants as httpConstants } from "node:http2";
import path from "node:path";
-// Environment variables with fallbacks
const port = process.env.PORT ?? process.argv[2] ?? 8433;
const serverRoot = process.env.SERVER_ROOT ?? "./app";
const keyPath = process.env.KEY_PATH ?? "./server.key";
@@ -10,27 +9,23 @@ const certPath = process.env.CERT_PATH ?? "./server.pem";
const hstsMaxAge = process.env.HSTS_MAX_AGE ?? "31536000";
const corsOrigin = process.env.CORS_ORIGIN ?? "*";
-/**
- * @type {Map<string, string>}
- * Maps file extensions to MIME types for proper content-type headers
- */
-const mimeType = new Map([
- [".ico", "image/x-icon"],
- [".html", "text/html"],
- [".js", "text/javascript"],
- [".json", "application/json"],
- [".css", "text/css"],
- [".png", "image/png"],
- [".jpg", "image/jpeg"],
- [".wav", "audio/wav"],
- [".mp3", "audio/mpeg"],
- [".svg", "image/svg+xml"],
- [".pdf", "application/pdf"],
- [".zip", "application/zip"],
- [".doc", "application/msword"],
- [".eot", "application/vnd.ms-fontobject"],
- [".ttf", "application/x-font-ttf"],
-]);
+const mimeType = {
+ ".css": "text/css",
+ ".doc": "application/msword",
+ ".eot": "application/vnd.ms-fontobject",
+ ".html": "text/html",
+ ".ico": "image/x-icon",
+ ".jpg": "image/jpeg",
+ ".js": "text/javascript",
+ ".json": "application/json",
+ ".mp3": "audio/mpeg",
+ ".pdf": "application/pdf",
+ ".png": "image/png",
+ ".svg": "image/svg+xml",
+ ".ttf": "application/x-font-ttf",
+ ".wav": "audio/wav",
+ ".zip": "application/zip",
+};
const {
HTTP2_HEADER_PATH: HEADER_PATH,
@@ -42,15 +37,15 @@ const {
const HEADER_ORIGIN = "origin";
/**
- * @type {import('node:http2').SecureServerOptions}
- * TLS options for the HTTP/2 server
+ * HTTP/2 secure server instance
+ * @type {import('node:http2').Http2SecureServer}
*/
-let options;
+let server;
try {
- options = {
+ server = createSecureServer({
cert: readFileSync(certPath),
key: readFileSync(keyPath),
- };
+ });
} catch (error) {
if (error && "code" in error && error.code === "ENOENT") {
console.error(`Certificate error: Could not find key or cert files at ${keyPath}, ${certPath}`);
@@ -61,12 +56,6 @@ try {
}
/**
- * HTTP/2 secure server instance
- * @type {import('node:http2').Http2SecureServer}
- */
-const server = createSecureServer(options);
-
-/**
* Handles stream errors and sends appropriate HTTP responses
* @param {unknown} err - The error object
* @param {import('node:http2').ServerHttp2Stream} stream - The HTTP/2 stream to respond on
@@ -116,11 +105,8 @@ const getResponseHeaders = (mimeType, ro) => {
* @param {number} flags - Stream flags
*/
server.on("stream", (stream, headers) => {
- /** @type {string} */
const reqPath = headers[HEADER_PATH] || "/";
- /** @type {string} */
const reqMethod = headers[HEADER_METHOD] || "GET";
- /** @type {string | undefined} */
const requestOrigin =
typeof headers[HEADER_ORIGIN] === "string" ? headers[HEADER_ORIGIN] : undefined;
@@ -159,16 +145,12 @@ server.on("stream", (stream, headers) => {
}
const ext = path.extname(fullPath);
- const responseMimeType = mimeType.get(ext) || "text/plain";
+ const responseMimeType = mimeType[ext] || "text/plain";
stream.respondWithFile(
fullPath,
- {
- ...getResponseHeaders(responseMimeType, requestOrigin),
- },
- {
- onError: (err) => handleStreamError(err, stream, requestOrigin),
- },
+ { ...getResponseHeaders(responseMimeType, requestOrigin) },
+ { onError: (err) => handleStreamError(err, stream, requestOrigin) },
);
});
@@ -192,7 +174,7 @@ const gracefulShutdown = (signal) => {
setTimeout(() => {
console.error("Forcing server closure after timeout");
process.exit(1);
- }, 10000);
+ }, 2000);
};
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
@@ -220,7 +202,7 @@ process.on("unhandledRejection", (reason, promise) => {
/**
* Starts the HTTP/2 server and begins listening for connections
*/
-server.listen(Number.parseInt(port.toString(), 10), "0.0.0.0", () => {
+server.listen(parseInt(port, 10), "localhost", () => {
console.log(`
Server running on https://localhost:${port}
Environment: ${process.env.NODE_ENV || "development"}