diff options
| author | Petri Hienonen <petri.hienonen@gmail.com> | 2025-11-03 11:19:15 +0200 |
|---|---|---|
| committer | Petri Hienonen <petri.hienonen@gmail.com> | 2025-11-04 13:16:38 +0200 |
| commit | a4ed99a370930b1a0c0f065906ed99c15a015fd4 (patch) | |
| tree | 36dbc74e78d24fea7cf58c13e0ecbc929f9f18f7 | |
| parent | b03ee7032b2ea2d4d22ab7ec1346b7c9331cfc17 (diff) | |
| download | housing-a4ed99a370930b1a0c0f065906ed99c15a015fd4.tar.zst | |
Update documentation
| -rw-r--r-- | README.adoc | 23 | ||||
| -rw-r--r-- | app/dom.js | 63 | ||||
| -rw-r--r-- | app/geometry.js | 52 | ||||
| -rw-r--r-- | app/main.js | 68 | ||||
| -rw-r--r-- | app/map.js | 549 | ||||
| -rw-r--r-- | app/models.js | 23 | ||||
| -rw-r--r-- | app/requirements.tsv | 20 | ||||
| -rw-r--r-- | app/svg.js | 327 | ||||
| -rw-r--r-- | jsconfig.json | 2 | ||||
| -rw-r--r-- | server.js | 72 |
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] @@ -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); } @@ -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 @@ -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 @@ -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"} |
