diff options
| author | Petri Hienonen <petri.hienonen@gmail.com> | 2025-11-09 22:59:02 +0200 |
|---|---|---|
| committer | Petri Hienonen <petri.hienonen@gmail.com> | 2025-11-11 15:35:03 +0200 |
| commit | 909773f9d253c61183cc1f9f6193656957946be5 (patch) | |
| tree | 136075e1946accedda0530dd25940b8931408c5a | |
| parent | be7ec90b500ac68e053f2b58feb085247ef95817 (diff) | |
| download | housing-909773f9d253c61183cc1f9f6193656957946be5.tar.zst | |
Add statistical areas
Diffstat (limited to '')
| -rw-r--r-- | README.adoc | 9 | ||||
| -rw-r--r-- | app/components.js | 864 | ||||
| -rw-r--r-- | app/dom.js | 410 | ||||
| -rw-r--r-- | app/index.html | 1 | ||||
| -rw-r--r-- | app/main.js | 377 | ||||
| -rw-r--r-- | app/map.js | 714 | ||||
| -rw-r--r-- | app/models.js | 200 | ||||
| -rw-r--r-- | download.js | 81 | ||||
| -rw-r--r-- | jsconfig.json | 2 |
9 files changed, 1853 insertions, 805 deletions
diff --git a/README.adoc b/README.adoc index d84decf..f50050b 100644 --- a/README.adoc +++ b/README.adoc @@ -67,12 +67,11 @@ 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. Currently only overview is parsed. +- Parse more data from data source. Currently only overview. - Images on modal open on click to a new window - Visual programming? Value function description with Javascript? - Notifications to user on new houses - Sharing via URL -- Real support for MultiLineString in geometry == Analysis Data processing @@ -83,3 +82,9 @@ WFS Capabilities can be found from: https://kartta.hel.fi/ws/geoserver/avoindata/wfs?version=2.0.0&request=GetCapabilities The node.js script `download.js` downloads the material. + +Description of the statistical data can be found from: +https://hri.fi/data/fi/dataset/helsingin-seudun-aluesarjat-tilastotietokannan-tiedot-paikkatietona + +, and the description at: +https://www.hel.fi/static/avoindata/dokumentit/Aluesarjat_Avainluvut_2024.pdf diff --git a/app/components.js b/app/components.js new file mode 100644 index 0000000..bb11138 --- /dev/null +++ b/app/components.js @@ -0,0 +1,864 @@ +import { Dom, DomOptions, ToastType } from "dom"; +import { AreaColorParameter, ColorParameter } from "map"; +import { District, Filters, House, Weights } from "models"; + +export class Widgets { + /** + * Show toast notification + * @param {string} message + * @param {ToastType} [type=ToastType.error] + */ + static toast(message, type = ToastType.error) { + return Dom.div( + new DomOptions({ + children: [Dom.p(message)], + classes: ["toast", `toast-${type}`], + id: "app-toast", + styles: { + background: + type === ToastType.error + ? "#f44336" + : type === ToastType.warning + ? "#ff9800" + : "#4caf50", + borderRadius: "4px", + boxShadow: "0 4px 12px rgba(0,0,0,0.15)", + color: "white", + fontSize: "14px", + fontWeight: "500", + maxWidth: "300px", + padding: "12px 20px", + position: "fixed", + right: "20px", + top: "20px", + transition: "all .3s ease", + zIndex: "10000", + }, + }), + ); + } + + /** + * Remove all children + * @param {HTMLElement} el + */ + static clear(el) { + while (el.firstChild) el.removeChild(el.firstChild); + } + + /** + * 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 output = Dom.span( + initialValue.toFixed(1), + new DomOptions({ + id: `${id}-value`, + styles: { color: "#0066cc", fontSize: "0.85rem", fontWeight: "bold", textAlign: "center" }, + }), + ); + + return Dom.div( + new DomOptions({ + children: [ + Dom.label( + id, + labelText, + new DomOptions({ + children: [ + Dom.span( + labelText, + new DomOptions({ + styles: { fontSize: "0.85rem" }, + }), + ), + Dom.span(" "), + output, + ], + }), + ), + Dom.input( + "range", + (e) => { + const target = /** @type {HTMLInputElement} */ (e.target); + const val = Number(target.value); + output.textContent = val.toFixed(1); + onChange(weightKey, val); + }, + "", + "", + new DomOptions({ + attributes: { max: "1", min: "0", step: "0.1", value: initialValue.toString() }, + id, + styles: { + margin: "0.5rem 0", + width: "100%", + }, + }), + ), + ], + styles: { display: "flex", flexDirection: "column", marginBottom: "1rem" }, + }), + ); + } + + /** + * Create a number filter input + * @param {string} id + * @param {string} labelText + * @param {(value: number | null) => void} onChange + * @returns {HTMLElement} + */ + static numberFilter(id, labelText, onChange) { + return Dom.div( + new DomOptions({ + children: [ + Dom.label( + id, + labelText, + new DomOptions({ + styles: { fontSize: "0.85rem", fontWeight: "bold", marginBottom: "0.25rem" }, + }), + ), + Dom.input( + "number", + (e) => { + const target = /** @type {HTMLInputElement} */ (e.target); + const raw = target.value.trim(); + onChange(raw === "" ? null : Number(raw)); + }, + "any", + "", + new DomOptions({ + id, + styles: { + border: "1px solid #ddd", + borderRadius: "4px", + fontSize: "0.9rem", + padding: "0.5rem", + }, + }), + ), + ], + styles: { display: "flex", flexDirection: "column", marginBottom: "1.75rem" }, + }), + ); + } +} + +export class Sidebar { + /** @type {HTMLElement} */ + #rootElement; + /** @type {boolean} */ + #collapsed = false; + /** @type {Filters} */ + #filters; + /** @type {Weights} */ + #weights; + /** @type {() => void} */ + #onFilterChange; + /** @type {(key: string, value: number) => void} */ + #onWeightChange; + /** @type {(param: string) => void} */ + #onColorChange; + /** @type {(param: string) => void} */ + #onAreaColorChange; + + /** + * @param {Filters} filters + * @param {Weights} weights + * @param {() => void} onFilterChange + * @param {(key: string, value: number) => void} onWeightChange + * @param {(param: string) => void} onColorChange + * @param {(param: string) => void} onAreaColorChange + */ + constructor(filters, weights, onFilterChange, onWeightChange, onColorChange, onAreaColorChange) { + this.#filters = filters; + this.#weights = weights; + this.#onFilterChange = onFilterChange; + this.#onWeightChange = onWeightChange; + this.#onColorChange = onColorChange; + this.#onAreaColorChange = onAreaColorChange; + this.#rootElement = this.#render(); + } + + /** + * Render sidebar container + * @returns {HTMLElement} + */ + #render() { + const sidebar = Dom.div( + new DomOptions({ + children: [ + // Toggle button + Dom.button( + "☰", + () => this.toggle(), + new DomOptions({ + id: "sidebar-toggle", + styles: { + background: "none", + border: "none", + color: "#333", + cursor: "pointer", + fontSize: "1.5rem", + padding: "0.5rem", + position: "absolute", + right: "0.5rem", + top: "0.5rem", + zIndex: "10", + }, + }), + ), + // Sidebar content + Dom.div( + new DomOptions({ + children: [ + // Map Colors Section + Dom.div( + new DomOptions({ + children: [ + Dom.heading( + 3, + "Map Colors", + new DomOptions({ + styles: { + color: "#333", + fontSize: "1.1rem", + margin: "0 0 1rem 0", + }, + }), + ), + Dom.div( + new DomOptions({ + children: [ + Dom.label( + "color-parameter", + "Color houses by", + new DomOptions({ + styles: { + fontSize: "0.85rem", + fontWeight: "bold", + marginBottom: "0.25rem", + }, + }), + ), + Dom.select( + (e) => { + const target = /** @type {HTMLSelectElement} */ (e.target); + this.#onColorChange(target.value); + }, + new DomOptions({ + children: [ + Dom.option(ColorParameter.price, "Price"), + Dom.option(ColorParameter.score, "Score"), + Dom.option(ColorParameter.year, "Construction Year"), + Dom.option(ColorParameter.area, "Living Area"), + ], + id: "color-parameter", + styles: { + border: "1px solid #ddd", + borderRadius: "4px", + fontSize: "0.9rem", + padding: "0.5rem", + width: "100%", + }, + }), + ), + ], + styles: { + display: "flex", + flexDirection: "column", + marginBottom: "1rem", + }, + }), + ), + Dom.div( + new DomOptions({ + children: [ + Dom.label( + "area-color-parameter", + "Color areas by", + new DomOptions({ + styles: { + fontSize: "0.85rem", + fontWeight: "bold", + marginBottom: "0.25rem", + }, + }), + ), + Dom.select( + (e) => { + const target = /** @type {HTMLSelectElement} */ (e.target); + this.#onAreaColorChange(target.value); + }, + new DomOptions({ + children: [ + Dom.option(AreaColorParameter.none, "None"), + Dom.option( + AreaColorParameter.foreignSpeakers, + "Foreign speakers", + ), + Dom.option( + AreaColorParameter.unemploymentRate, + "Unemployment rate", + ), + Dom.option(AreaColorParameter.averageIncome, "Average income"), + Dom.option( + AreaColorParameter.higherEducation, + "Higher education", + ), + ], + id: "area-color-parameter", + styles: { + border: "1px solid #ddd", + borderRadius: "4px", + fontSize: "0.9rem", + padding: "0.5rem", + width: "100%", + }, + }), + ), + ], + styles: { display: "flex", flexDirection: "column" }, + }), + ), + ], + styles: { + borderBottom: "1px solid #eee", + paddingBottom: "1rem", + }, + }), + ), + // Filters Section + Dom.div( + new DomOptions({ + children: [ + Dom.heading( + 3, + "Filters", + new DomOptions({ + styles: { + color: "#333", + fontSize: "1.1rem", + margin: "1rem 0 1rem 0", + }, + }), + ), + Dom.div( + new DomOptions({ + children: [ + Widgets.numberFilter("min-price", "Min price (€)", (v) => { + this.#filters.minPrice = v ?? 0; + this.#onFilterChange(); + }), + Widgets.numberFilter("max-price", "Max price (€)", (v) => { + this.#filters.maxPrice = v ?? Number.POSITIVE_INFINITY; + this.#onFilterChange(); + }), + ], + id: "price-row", + styles: { + display: "flex", + gap: "0.5rem", + }, + }), + ), + Widgets.numberFilter("min-year", "Min year", (v) => { + this.#filters.minYear = v ?? 0; + this.#onFilterChange(); + }), + Widgets.numberFilter("min-area", "Min area (m²)", (v) => { + this.#filters.minArea = v ?? 0; + this.#onFilterChange(); + }), + Dom.div( + new DomOptions({ + children: [ + Dom.label( + "district-select", + "Districts", + new DomOptions({ + styles: { + fontSize: "0.85rem", + fontWeight: "bold", + marginBottom: "0.25rem", + }, + }), + ), + Dom.select( + (e) => { + const target = /** @type {HTMLSelectElement} */ (e.target); + const selectedOptions = Array.from(target.selectedOptions).map( + (opt) => opt.value, + ); + this.#filters.districts = selectedOptions; + this.#onFilterChange(); + }, + new DomOptions({ + attributes: { multiple: "true" }, + children: [], + id: "district-select", + styles: { + border: "1px solid #ddd", + borderRadius: "4px", + minHeight: "120px", + padding: "0.5rem", + width: "100%", + }, + }), + ), + ], + id: "district-multi-select", + styles: { display: "flex", flexDirection: "column", marginTop: "1rem" }, + }), + ), + ], + styles: { + borderBottom: "1px solid #eee", + paddingBottom: "1rem", + }, + }), + ), + // Weights Section + Dom.div( + new DomOptions({ + children: [ + Dom.heading( + 3, + "Weights", + new DomOptions({ + styles: { + color: "#333", + fontSize: "1.1rem", + margin: "1rem 0 1rem 0", + }, + }), + ), + Widgets.slider( + "w-price", + "Price weight", + "price", + this.#weights.price, + this.#onWeightChange, + ), + Widgets.slider( + "w-market", + "Market distance", + "distanceMarket", + this.#weights.distanceMarket, + this.#onWeightChange, + ), + Widgets.slider( + "w-school", + "School distance", + "distanceSchool", + this.#weights.distanceSchool, + this.#onWeightChange, + ), + Widgets.slider( + "w-crime", + "Crime rate", + "crimeRate", + this.#weights.crimeRate, + this.#onWeightChange, + ), + Widgets.slider( + "w-safety", + "Safety index", + "safety", + this.#weights.safety, + this.#onWeightChange, + ), + Widgets.slider( + "w-students", + "S2 students", + "s2Students", + this.#weights.s2Students, + this.#onWeightChange, + ), + Widgets.slider( + "w-railway", + "Railway distance", + "distanceRailway", + this.#weights.distanceRailway, + this.#onWeightChange, + ), + Widgets.slider( + "w-year", + "Construction year", + "constructionYear", + this.#weights.constructionYear, + this.#onWeightChange, + ), + ], + }), + ), + ], + id: "sidebar-content", + }), + ), + ], + id: "sidebar", + styles: { + background: "#fff", + borderRight: "1px solid #ddd", + display: "flex", + flexDirection: "column", + flexShrink: "0", + overflowY: "auto", + padding: "1rem", + position: "relative", + transition: "width 0.3s ease", + width: "300px", + }, + }), + ); + return sidebar; + } + + /** + * Get the root DOM element + * @returns {HTMLElement} + */ + render() { + return this.#rootElement; + } + + /** Show the sidebar */ + show() { + if (this.#collapsed) { + this.toggle(); + } + } + + /** Hide the sidebar */ + hide() { + if (!this.#collapsed) { + this.toggle(); + } + } + + /** Toggle sidebar visibility */ + toggle() { + this.#collapsed = !this.#collapsed; + const sidebarContent = this.#rootElement.querySelector("#sidebar-content"); + const toggleButton = this.#rootElement.querySelector("#sidebar-toggle"); + + if (this.#collapsed) { + this.#rootElement.style.width = "0"; + this.#rootElement.style.padding = "0"; + if (sidebarContent) sidebarContent.style.display = "none"; + if (toggleButton) { + toggleButton.textContent = "☰"; + toggleButton.style.right = "0.5rem"; + } + } else { + this.#rootElement.style.width = "300px"; + this.#rootElement.style.padding = "1rem"; + if (sidebarContent) sidebarContent.style.display = "block"; + if (toggleButton) { + toggleButton.textContent = "☰"; + toggleButton.style.right = "0.5rem"; + } + } + } + + /** + * Update district options in the multi-select + * @param {District[]} districts + * @param {House[]} houses + */ + updateDistricts(districts, houses) { + const districtOptions = this.#renderDistrictOptions(districts, houses); + const districtSelect = this.#rootElement.querySelector("#district-select"); + if (districtSelect) { + districtSelect.append(...districtOptions); + } + } + + /** + * Set the area color parameter in the dropdown + * @param {string} param + */ + setAreaColorParameter(param) { + const areaColorSelect = this.#rootElement.querySelector("#area-color-parameter"); + if (areaColorSelect) { + areaColorSelect.value = param; + } + } + + /** + * Render district options for multi-select + * @param {District[]} _districts + * @param {House[]} houses + * @returns {HTMLOptionElement[]} + */ + #renderDistrictOptions(_districts, houses) { + const houseDistricts = [...new Set(houses.map((h) => h.district).filter((d) => d))].sort(); + return houseDistricts.map((districtName) => Dom.option(districtName, districtName)); + } +} + +export class Modal { + /** @type {HTMLDialogElement} */ + #dialog; + /** @type {AbortController} */ + #abortController; + /** @type {number | undefined} */ + #timer; + /** @type {boolean} */ + #persistent; + /** @type {House} */ + #house; + /** @type {() => void} */ + #onHide; + /** @type {() => void} */ + #onClearMapTimer; + + /** + * Build modal content for a house + * @param {House} house + * @returns {DocumentFragment} + */ + static content(house) { + const frag = document.createDocumentFragment(); + frag.appendChild( + Dom.div( + new DomOptions({ + children: [ + Dom.heading( + 2, + house.address, + new DomOptions({ + styles: { color: "#333", fontSize: "20px", margin: "0" }, + }), + ), + Dom.span( + `Score: ${house.scores.current.toFixed(1)}`, + new DomOptions({ + styles: { + background: "#e8f5e9", + borderRadius: "4px", + color: "#2e7d32", + fontSize: "16px", + fontWeight: "bold", + padding: "4px 8px", + }, + }), + ), + ], + id: "modal-header", + styles: { + alignItems: "center", + display: "flex", + justifyContent: "space-between", + marginBottom: "20px", + }, + }), + ), + ); + + const grid = Dom.div( + new DomOptions({ + styles: { + display: "grid", + gap: "15px", + gridTemplateColumns: "repeat(2,1fr)", + marginBottom: "20px", + }, + }), + ); + const details = [ + { 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" }, + { 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( + new DomOptions({ + children: [ + Dom.span( + label, + new DomOptions({ + styles: { fontSize: "14px", fontWeight: "bold", marginBottom: "4px" }, + }), + ), + Dom.span(value, new DomOptions({ styles: { color: "#333", fontSize: "14px" } })), + ], + }), + ); + grid.appendChild(item); + } + frag.appendChild(grid); + frag.appendChild( + Dom.div( + new DomOptions({ + children: [ + Dom.span( + "Description", + new DomOptions({ + styles: { fontSize: "14px", fontWeight: "bold", marginBottom: "5px" }, + }), + ), + Dom.p( + house.description ?? "No description available.", + new DomOptions({ + styles: { color: "#333", fontSize: "14px", lineHeight: "1.4", marginTop: "5px" }, + }), + ), + ], + styles: { marginBottom: "20px" }, + }), + ), + ); + + if (house.images?.length) { + const imgSect = Dom.div( + new DomOptions({ + children: [ + Dom.div( + new DomOptions({ + id: "img_title", + styles: { fontSize: "14px", fontWeight: "bold", marginBottom: "10px" }, + }), + ), + Dom.div( + new DomOptions({ + children: house.images.slice(0, 3).map((src) => { + return Dom.img( + src, + new DomOptions({ + attributes: { loading: "lazy" }, + styles: { borderRadius: "4px", flexShrink: "0", height: "100px" }, + }), + ); + }), + + styles: { display: "flex", gap: "10px", overflowX: "auto", paddingBottom: "5px" }, + }), + ), + ], + styles: { marginBottom: "20px" }, + }), + ); + frag.appendChild(imgSect); + } + + return frag; + } + + /** + * @param {House} house + * @param {boolean} persistent + * @param {object} positionStyles + * @param {() => void} onHide + * @param {() => void} onClearMapTimer + */ + constructor(house, persistent, positionStyles, onHide, onClearMapTimer) { + this.#house = house; + this.#persistent = persistent; + this.#onHide = onHide; + this.#onClearMapTimer = onClearMapTimer; + this.#abortController = new AbortController(); + this.#dialog = document.createElement("dialog"); + + Object.assign( + this.#dialog.style, + { + background: "white", + border: "none", + borderRadius: "8px", + boxShadow: "0 4px 20px rgba(0,0,0,0.2)", + maxHeight: "80vh", + maxWidth: "600px", + overflowY: "auto", + padding: "20px", + position: "fixed", + top: "50%", + transform: "translateY(-50%)", + width: "90%", + zIndex: "1000", + }, + positionStyles, + ); + + this.#dialog.append( + Dom.button( + "x", + () => this.hide(), + new DomOptions({ + id: "close-modal-btn", + styles: { + background: "none", + border: "none", + color: "#666", + cursor: "pointer", + fontSize: "24px", + position: "absolute", + right: "10px", + top: "10px", + }, + }), + ), + Modal.content(house), + ); + + // Add event listeners with AbortController + this.#dialog.addEventListener("close", () => this.hide(), { + signal: this.#abortController.signal, + }); + this.#dialog.addEventListener( + "mouseenter", + () => { + clearTimeout(this.#timer); + this.#onClearMapTimer(); + }, + { signal: this.#abortController.signal }, + ); + this.#dialog.addEventListener( + "mouseleave", + () => { + if (!this.#persistent) { + this.#timer = window.setTimeout(() => this.hide(), 200); + } + }, + { signal: this.#abortController.signal }, + ); + } + + render() { + return this.#dialog; + } + + show() { + if (this.#persistent) { + this.#dialog.showModal(); + } else { + this.#dialog.show(); + } + } + + hide() { + clearTimeout(this.#timer); + this.#dialog.close(); + this.#dialog.remove(); + this.#abortController.abort(); + this.#onHide(); + } +} @@ -1,6 +1,3 @@ -// dom.js -import { House } from "models"; - export class DomOptions { attributes; children; @@ -214,410 +211,3 @@ export class Dom { return p; } } - -export class Widgets { - /** - * Show toast notification - * @param {string} message - * @param {ToastType} [type=ToastType.error] - */ - static toast(message, type = ToastType.error) { - return Dom.div( - new DomOptions({ - children: [Dom.p(message)], - classes: ["toast", `toast-${type}`], - id: "app-toast", - styles: { - background: - type === ToastType.error - ? "#f44336" - : type === ToastType.warning - ? "#ff9800" - : "#4caf50", - borderRadius: "4px", - boxShadow: "0 4px 12px rgba(0,0,0,0.15)", - color: "white", - fontSize: "14px", - fontWeight: "500", - maxWidth: "300px", - padding: "12px 20px", - position: "fixed", - right: "20px", - top: "20px", - transition: "all .3s ease", - zIndex: "10000", - }, - }), - ); - } - - /** - * Remove all children - * @param {HTMLElement} el - */ - static clear(el) { - while (el.firstChild) el.removeChild(el.firstChild); - } - - /** - * 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 output = Dom.span( - initialValue.toFixed(1), - new DomOptions({ - id: `${id}-value`, - styles: { color: "#0066cc", fontSize: "0.85rem", fontWeight: "bold", textAlign: "center" }, - }), - ); - - return Dom.div( - new DomOptions({ - children: [ - Dom.label( - id, - labelText, - new DomOptions({ - children: [ - Dom.span( - labelText, - new DomOptions({ - styles: { fontSize: "0.85rem" }, - }), - ), - Dom.span(" "), - output, - ], - }), - ), - Dom.input( - "range", - (e) => { - const target = /** @type {HTMLInputElement} */ (e.target); - const val = Number(target.value); - output.textContent = val.toFixed(1); - onChange(weightKey, val); - }, - "", - "", - new DomOptions({ - attributes: { max: "1", min: "0", step: "0.1", value: initialValue.toString() }, - id, - styles: { - margin: "0.5rem 0", - width: "100%", - }, - }), - ), - ], - styles: { display: "flex", flexDirection: "column", marginBottom: "1rem" }, - }), - ); - } - - /** - * Create a number filter input - * @param {string} id - * @param {string} labelText - * @param {(value: number | null) => void} onChange - * @returns {HTMLElement} - */ - static numberFilter(id, labelText, onChange) { - return Dom.div( - new DomOptions({ - children: [ - Dom.label( - id, - labelText, - new DomOptions({ - styles: { fontSize: "0.85rem", fontWeight: "bold", marginBottom: "0.25rem" }, - }), - ), - Dom.input( - "number", - (e) => { - const target = /** @type {HTMLInputElement} */ (e.target); - const raw = target.value.trim(); - onChange(raw === "" ? null : Number(raw)); - }, - "any", - "", - new DomOptions({ - id, - styles: { - border: "1px solid #ddd", - borderRadius: "4px", - fontSize: "0.9rem", - padding: "0.5rem", - }, - }), - ), - ], - styles: { display: "flex", flexDirection: "column", marginBottom: "1.75rem" }, - }), - ); - } -} - -export class Modal { - /** @type {HTMLDialogElement} */ - #dialog; - /** @type {AbortController} */ - #abortController; - /** @type {number | undefined} */ - #timer; - /** @type {boolean} */ - #persistent; - /** @type {House} */ - #house; - /** @type {() => void} */ - #onHide; - /** @type {() => void} */ - #onClearMapTimer; - - /** - * Build modal content for a house - * @param {House} house - * @returns {DocumentFragment} - */ - static buildModalContent(house) { - const frag = document.createDocumentFragment(); - frag.appendChild( - Dom.div( - new DomOptions({ - children: [ - Dom.heading( - 2, - house.address, - new DomOptions({ - styles: { color: "#333", fontSize: "20px", margin: "0" }, - }), - ), - Dom.span( - `Score: ${house.scores.current.toFixed(1)}`, - new DomOptions({ - styles: { - background: "#e8f5e9", - borderRadius: "4px", - color: "#2e7d32", - fontSize: "16px", - fontWeight: "bold", - padding: "4px 8px", - }, - }), - ), - ], - id: "modal-header", - styles: { - alignItems: "center", - display: "flex", - justifyContent: "space-between", - marginBottom: "20px", - }, - }), - ), - ); - - const grid = Dom.div( - new DomOptions({ - styles: { - display: "grid", - gap: "15px", - gridTemplateColumns: "repeat(2,1fr)", - marginBottom: "20px", - }, - }), - ); - const details = [ - { 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" }, - { 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( - new DomOptions({ - children: [ - Dom.span( - label, - new DomOptions({ - styles: { fontSize: "14px", fontWeight: "bold", marginBottom: "4px" }, - }), - ), - Dom.span(value, new DomOptions({ styles: { color: "#333", fontSize: "14px" } })), - ], - }), - ); - grid.appendChild(item); - } - frag.appendChild(grid); - frag.appendChild( - Dom.div( - new DomOptions({ - children: [ - Dom.span( - "Description", - new DomOptions({ - styles: { fontSize: "14px", fontWeight: "bold", marginBottom: "5px" }, - }), - ), - Dom.p( - house.description ?? "No description available.", - new DomOptions({ - styles: { color: "#333", fontSize: "14px", lineHeight: "1.4", marginTop: "5px" }, - }), - ), - ], - styles: { marginBottom: "20px" }, - }), - ), - ); - - if (house.images?.length) { - const imgSect = Dom.div( - new DomOptions({ - children: [ - Dom.div( - new DomOptions({ - id: "img_title", - styles: { fontSize: "14px", fontWeight: "bold", marginBottom: "10px" }, - }), - ), - Dom.div( - new DomOptions({ - children: house.images.slice(0, 3).map((src) => { - return Dom.img( - src, - new DomOptions({ - attributes: { loading: "lazy" }, - styles: { borderRadius: "4px", flexShrink: "0", height: "100px" }, - }), - ); - }), - - styles: { display: "flex", gap: "10px", overflowX: "auto", paddingBottom: "5px" }, - }), - ), - ], - styles: { marginBottom: "20px" }, - }), - ); - frag.appendChild(imgSect); - } - - return frag; - } - - /** - * @param {House} house - * @param {boolean} persistent - * @param {object} positionStyles - * @param {() => void} onHide - * @param {() => void} onClearMapTimer - */ - constructor(house, persistent, positionStyles, onHide, onClearMapTimer) { - this.#house = house; - this.#persistent = persistent; - this.#onHide = onHide; - this.#onClearMapTimer = onClearMapTimer; - this.#abortController = new AbortController(); - this.#dialog = document.createElement("dialog"); - - Object.assign( - this.#dialog.style, - { - background: "white", - border: "none", - borderRadius: "8px", - boxShadow: "0 4px 20px rgba(0,0,0,0.2)", - maxHeight: "80vh", - maxWidth: "600px", - overflowY: "auto", - padding: "20px", - position: "fixed", - top: "50%", - transform: "translateY(-50%)", - width: "90%", - zIndex: "1000", - }, - positionStyles, - ); - - this.#dialog.append( - Dom.button( - "x", - () => this.hide(), - new DomOptions({ - id: "close-modal-btn", - styles: { - background: "none", - border: "none", - color: "#666", - cursor: "pointer", - fontSize: "24px", - position: "absolute", - right: "10px", - top: "10px", - }, - }), - ), - Modal.buildModalContent(house), - ); - - // Add event listeners with AbortController - this.#dialog.addEventListener("close", () => this.hide(), { - signal: this.#abortController.signal, - }); - this.#dialog.addEventListener( - "mouseenter", - () => { - clearTimeout(this.#timer); - this.#onClearMapTimer(); - }, - { signal: this.#abortController.signal }, - ); - this.#dialog.addEventListener( - "mouseleave", - () => { - if (!this.#persistent) { - this.#timer = setTimeout(() => this.hide(), 200); - } - }, - { signal: this.#abortController.signal }, - ); - } - - render() { - return this.#dialog; - } - - show() { - if (this.#persistent) { - this.#dialog.showModal(); - } else { - this.#dialog.show(); - } - } - - hide() { - clearTimeout(this.#timer); - this.#dialog.close(); - this.#dialog.remove(); - this.#abortController.abort(); - this.#onHide(); - } -} diff --git a/app/index.html b/app/index.html index 479eafa..aca331c 100644 --- a/app/index.html +++ b/app/index.html @@ -47,6 +47,7 @@ { "imports": { "dom": "./dom.js", + "components": "./components.js", "geom": "./geometry.js", "map": "./map.js", "models": "./models.js", diff --git a/app/main.js b/app/main.js index 1273a11..3995a07 100644 --- a/app/main.js +++ b/app/main.js @@ -1,12 +1,15 @@ -// main.js -import { Dom, DomOptions, Modal, Widgets } from "dom"; -import { ColorParameter, MapEl } from "map"; +// main.js - Updated with Sidebar class + +import { Modal, Sidebar } from "components"; +import { Dom, DomOptions } from "dom"; +import { AreaColorParameter, ColorParameter, MapEl } from "map"; import { DataProvider, District, Filters, House, ScoringEngine, + StatisticalArea, TrainStation, TrainTracks, Weights, @@ -19,6 +22,8 @@ export class App { #trainTracks = []; /** @type {TrainStation[]} */ #trainStations = []; + /** @type {StatisticalArea[]} */ + #statAreas = []; /** @type {House[]} */ #filtered = []; /** @type {Filters} */ @@ -31,14 +36,16 @@ export class App { #map; /** @type {HTMLElement} */ #stats; - /** @type {HTMLElement} */ - #controls; + /** @type {Sidebar} */ + #sidebar; /** @type {Modal|null} */ #modal = null; /** @type {boolean} */ #persistent = false; /** @type {string} */ #colorParameter = ColorParameter.price; + /** @type {string} */ + #areaColorParameter = AreaColorParameter.unemploymentRate; constructor() { // Set up main layout container @@ -50,29 +57,14 @@ export class App { margin: "0", }); - this.#controls = App.buildControls( + // Create sidebar instance + this.#sidebar = new Sidebar( this.#filters, this.#weights, - () => { - this.#filtered = this.#houses.filter((h) => h.matchesFilters(this.#filters)); - if (this.#map) { - const filteredIds = this.#filtered.map((h) => h.id); - this.#map.updateHouseVisibility(filteredIds); - } - this.#updateStats(); - }, - (key, value) => { - if (key in this.#weights) { - this.#weights[/** @type {keyof Weights} */ (key)] = value; - } - App.#recalculateScores(this.#houses, this.#weights); - this.#map?.setColorParameter(this.#colorParameter); - this.#updateStats(); - }, - (param) => { - this.#colorParameter = param; - this.#map?.setColorParameter(this.#colorParameter); - }, + () => this.#onFilterChange(), + (key, value) => this.#onWeightChange(key, value), + (param) => this.#onColorChange(param), + (param) => this.#onAreaColorChange(param), ); this.#map = new MapEl({ @@ -125,7 +117,7 @@ export class App { Dom.div( new DomOptions({ children: [ - this.#controls, + this.#sidebar.render(), Dom.div( new DomOptions({ children: [this.#map.svg, this.#stats], @@ -134,7 +126,7 @@ export class App { display: "flex", flex: "1", flexDirection: "column", - minWidth: "0", // Prevents flex overflow + minWidth: "0", }, }), ), @@ -152,246 +144,47 @@ export class App { } /** - * Build controls container - * @param {Filters} filters - * @param {Weights} weights - * @param {() => void} onFilterChange - * @param {(key: string, value: number) => void} onWeightChange - * @param {(param: string) => void} onColorChange - * @returns {HTMLElement} + * Handle filter changes */ - static buildControls(filters, weights, onFilterChange, onWeightChange, onColorChange) { - const controls = Dom.div( - new DomOptions({ - children: [ - Dom.div( - new DomOptions({ - children: [ - Dom.heading( - 3, - "Map Colors", - new DomOptions({ - styles: { - color: "#333", - fontSize: "1.1rem", - margin: "0 0 1rem 0", - }, - }), - ), - Dom.div( - new DomOptions({ - children: [ - Dom.label( - "color-parameter", - "Color houses by", - new DomOptions({ - styles: { - fontSize: "0.85rem", - fontWeight: "bold", - marginBottom: "0.25rem", - }, - }), - ), - Dom.select( - (e) => { - const target = /** @type {HTMLSelectElement} */ (e.target); - onColorChange(target.value); - }, - new DomOptions({ - children: [ - Dom.option(ColorParameter.price, "Price"), - Dom.option(ColorParameter.score, "Score"), - Dom.option(ColorParameter.year, "Construction Year"), - Dom.option(ColorParameter.area, "Living Area"), - ], - id: "color-parameter", - styles: { - border: "1px solid #ddd", - borderRadius: "4px", - fontSize: "0.9rem", - padding: "0.5rem", - }, - }), - ), - ], - styles: { display: "flex", flexDirection: "column" }, - }), - ), - ], - styles: { - borderBottom: "1px solid #eee", - paddingBottom: "1rem", - }, - }), - ), - ], - id: "color-section", - styles: { - background: "#fff", - borderRight: "1px solid #ddd", - display: "flex", - flexDirection: "column", - flexShrink: "0", - gap: "1rem", - overflowY: "auto", - padding: "1rem", - width: "300px", - }, - }), - ); - - controls.append( - Dom.div( - new DomOptions({ - children: [ - Dom.heading( - 3, - "Filters", - new DomOptions({ - styles: { - color: "#333", - fontSize: "1.1rem", - margin: "0 0 1rem 0", - }, - }), - ), - - Dom.div( - new DomOptions({ - children: [ - Widgets.numberFilter("min-price", "Min price (€)", (v) => { - filters.minPrice = v ?? 0; - onFilterChange(); - }), + #onFilterChange() { + this.#filtered = this.#houses.filter((h) => h.matchesFilters(this.#filters)); + if (this.#map) { + const filteredIds = this.#filtered.map((h) => h.id); + this.#map.updateHouseVisibility(filteredIds); + } + this.#updateStats(); + } - Widgets.numberFilter("max-price", "Max price (€)", (v) => { - filters.maxPrice = v ?? Number.POSITIVE_INFINITY; - onFilterChange(); - }), - ], - id: "price-row", - styles: { - display: "flex", - gap: "0.5rem", - }, - }), - ), - Widgets.numberFilter("min-year", "Min year", (v) => { - filters.minYear = v ?? 0; - onFilterChange(); - }), + /** + * Handle weight changes + * @param {string} key + * @param {number} value + */ + #onWeightChange(key, value) { + if (key in this.#weights) { + this.#weights[/** @type {keyof Weights} */ (key)] = value; + } + App.#recalculateScores(this.#houses, this.#weights); + this.#map?.setColorParameter(this.#colorParameter); + this.#updateStats(); + } - Widgets.numberFilter("min-area", "Min area (m²)", (v) => { - filters.minArea = v ?? 0; - onFilterChange(); - }), - Dom.div( - new DomOptions({ - children: [ - Dom.label( - "district-select", - "Districts", - new DomOptions({ - styles: { fontSize: "0.85rem", fontWeight: "bold", marginBottom: "0.25rem" }, - }), - ), - Dom.select( - (e) => { - const target = /** @type {HTMLSelectElement} */ (e.target); - const selectedOptions = Array.from(target.selectedOptions).map( - (opt) => opt.value, - ); - filters.districts = selectedOptions; - onFilterChange(); - }, - new DomOptions({ - attributes: { multiple: "true" }, - children: [], - id: "district-select", - styles: { - border: "1px solid #ddd", - borderRadius: "4px", - minHeight: "120px", - padding: "0.5rem", - }, - }), - ), - ], - id: "district-multi-select", - styles: { display: "flex", flexDirection: "column" }, - }), - ), - ], - id: "filter-section", - styles: { - borderBottom: "1px solid #eee", - paddingBottom: "1rem", - }, - }), - ), - Dom.div( - new DomOptions({ - children: [ - Dom.heading( - 3, - "Weights", - new DomOptions({ - styles: { - color: "#333", - fontSize: "1.1rem", - margin: "0 0 1rem 0", - }, - }), - ), - Widgets.slider("w-price", "Price weight", "price", weights.price, onWeightChange), - Widgets.slider( - "w-market", - "Market distance", - "distanceMarket", - weights.distanceMarket, - onWeightChange, - ), - Widgets.slider( - "w-school", - "School distance", - "distanceSchool", - weights.distanceSchool, - onWeightChange, - ), - Widgets.slider("w-crime", "Crime rate", "crimeRate", weights.crimeRate, onWeightChange), - Widgets.slider("w-safety", "Safety index", "safety", weights.safety, onWeightChange), - Widgets.slider( - "w-students", - "S2 students", - "s2Students", - weights.s2Students, - onWeightChange, - ), - Widgets.slider( - "w-railway", - "Railway distance", - "distanceRailway", - weights.distanceRailway, - onWeightChange, - ), - Widgets.slider( - "w-year", - "Construction year", - "constructionYear", - weights.constructionYear, - onWeightChange, - ), - ], - id: "weights-section", - styles: { - borderBottom: "1px solid #eee", - paddingBottom: "1rem", - }, - }), - ), - ); + /** + * Handle color parameter changes + * @param {string} param + */ + #onColorChange(param) { + this.#colorParameter = param; + this.#map?.setColorParameter(this.#colorParameter); + } - return controls; + /** + * Handle area color parameter changes + * @param {string} param + */ + #onAreaColorChange(param) { + this.#areaColorParameter = param; + this.#map?.setAreaColorParameter(this.#areaColorParameter); } /** @@ -441,13 +234,14 @@ export class App { document.body.appendChild(this.#modal.render()); this.#modal.show(); } + /** * Load data and initialize application * @param {HTMLElement} loading */ async #initialize(loading) { try { - const [districts, houses, trainStations, trainTracks, coastLine, mainRoads] = + const [districts, houses, trainStations, trainTracks, coastLine, mainRoads, statAreas] = await Promise.all([ DataProvider.getDistricts(), DataProvider.getHouses(), @@ -455,11 +249,13 @@ export class App { DataProvider.getTrainTracks(), DataProvider.getCoastline(), DataProvider.getMainRoads(), + DataProvider.getStatisticalAreas(), ]); this.#districts = districts; this.#houses = houses; this.#trainStations = trainStations; this.#trainTracks = trainTracks; + this.#statAreas = statAreas; this.#filtered = houses.slice(); @@ -470,15 +266,16 @@ export class App { trainTracks, trainStations, houses, + statAreas, this.#colorParameter, ); - // Populate district multi-select - const districtOptions = App.#renderDistrictOptions(this.#districts, this.#houses); - const districtSelect = this.#controls.querySelector("#district-select"); - if (districtSelect) { - districtSelect.append(...districtOptions); - } + // Set default area coloring to unemployment rate + this.#map.setAreaColorParameter(this.#areaColorParameter); + + // Update sidebar with districts and area color parameter + this.#sidebar.updateDistricts(this.#districts, this.#houses); + this.#sidebar.setAreaColorParameter(this.#areaColorParameter); this.#updateStats(); } finally { @@ -487,18 +284,6 @@ export class App { } /** - * Render district options for multi-select - * @param {District[]} _districts - * @param {House[]} houses - * @returns {HTMLOptionElement[]} - */ - static #renderDistrictOptions(_districts, houses) { - // Get unique districts from houses (they might have districts not in the district polygons) - const houseDistricts = [...new Set(houses.map((h) => h.district).filter((d) => d))].sort(); - return houseDistricts.map((districtName) => Dom.option(districtName, districtName)); - } - - /** * Recalculate scores statically * @param {House[]} houses * @param {Weights} weights @@ -509,16 +294,32 @@ export class App { } } + /** + * Update statistics display using DOM methods + */ #updateStats() { const count = this.#filtered.length; const avg = count ? Math.round(this.#filtered.reduce((s, h) => s + h.scores.current, 0) / count) : 0; - this.#stats.innerHTML = ` - <strong>${count}</strong> houses shown - • Average score: <strong>${avg}</strong> - • Use weights sliders to adjust scoring - `; + + // Clear existing content + this.#stats.innerHTML = ""; + + // Create elements using DOM methods + const countStrong = document.createElement("strong"); + countStrong.textContent = count.toString(); + + const avgStrong = document.createElement("strong"); + avgStrong.textContent = avg.toString(); + + // Append all elements + this.#stats.append( + countStrong, + document.createTextNode(" houses shown • Average score: "), + avgStrong, + document.createTextNode(" • Use weights sliders to adjust scoring"), + ); } } @@ -1,5 +1,5 @@ import { Bounds, Collection, Feature, LineString, MultiLineString, Point } from "geom"; -import { District, House, TrainStation, TrainTracks } from "models"; +import { District, House, StatisticalArea, TrainStation, TrainTracks } from "models"; import { Svg, SvgOptions } from "svg"; /** @@ -14,6 +14,18 @@ export const ColorParameter = { }; /** + * Area color parameters for statistical areas + * @enum {string} + */ +export const AreaColorParameter = { + averageIncome: "averageIncome", + foreignSpeakers: "foreignSpeakers", + higherEducation: "higherEducation", + none: "none", + unemploymentRate: "unemploymentRate", +}; + +/** * Math utility functions */ export class MapMath { @@ -66,8 +78,12 @@ export class MapEl { svg; /** @type {House[]} */ #houses = []; + /** @type {StatisticalArea[]} */ + #statAreas = []; /** @type {SVGGElement|null} */ #housesGroup = null; + /** @type {SVGGElement|null} */ + #statAreasGroup = null; /** @type {Function|null} */ #onHouseClick; /** @type {Function} */ @@ -78,6 +94,14 @@ export class MapEl { #persistentModal = false; /** @type {Bounds|null} */ #fullBounds = null; + /** @type {Point|null} */ + #centerPoint = null; + /** @type {number} */ + #viewHeightMeters = 10000; // Initial view height in meters + /** @type {string} */ + #areaColorParameter = AreaColorParameter.none; + /** @type {Object} */ + #statAreaRanges = {}; /** * @param {Object} options @@ -96,6 +120,7 @@ export class MapEl { display: "block", flex: "1", minHeight: "0", + touchAction: "none", // Important for pinch zoom }, }), ); @@ -103,7 +128,7 @@ export class MapEl { this.svg = svg; this.#onHouseClick = options.onHouseClick; this.#onHouseHover = options.onHouseHover; - this.#enablePanning(this.svg); + this.#enableControls(this.svg); } /** @@ -114,10 +139,102 @@ export class MapEl { const cosFactor = Math.cos((avgLat * Math.PI) / 180); const width = (bounds.maxX - bounds.minX) * cosFactor; const height = bounds.maxY - bounds.minY; + + // Calculate initial center point and view height + this.#centerPoint = new Point(bounds.minX + width / 2, bounds.minY + height / 2); + this.#viewHeightMeters = this.#calculateViewHeightMeters(height); + this.svg.setAttribute("viewBox", `${bounds.minX} ${-bounds.maxY} ${width} ${height}`); } /** + * Calculate view height in meters based on latitude and view height in degrees + * @param {number} heightDegrees + * @returns {number} + */ + #calculateViewHeightMeters(heightDegrees) { + // Approximate conversion: 1 degree latitude ≈ 111,000 meters + // 1 degree longitude varies by latitude: 111,000 * cos(latitude) + return heightDegrees * 111000; + } + + /** + * Calculate view height in degrees based on meters and latitude + * @param {number} meters + * @returns {number} + */ + #calculateViewHeightDegrees(meters) { + return meters / 111000; + } + + /** + * Zoom the map to a specific scale and center point + * @param {number} scaleFactor + * @param {Point|null} zoomCenter + */ + #zoom(scaleFactor, zoomCenter = null) { + const vb = this.svg.viewBox.baseVal; + const currentCenter = new Point(vb.x + vb.width / 2, vb.y + vb.height / 2); + + // Calculate new view height in meters + this.#viewHeightMeters *= scaleFactor; + + // Clamp view height to reasonable limits (100m to 1000km) + this.#viewHeightMeters = MapMath.clamp(this.#viewHeightMeters, 100, 1000000); + + // Calculate new view height in degrees + const newHeightDegrees = this.#calculateViewHeightDegrees(this.#viewHeightMeters); + + // Calculate new width based on aspect ratio + const aspectRatio = vb.width / vb.height; + const newWidthDegrees = newHeightDegrees * aspectRatio; + + // Determine zoom center point + let zoomPoint = currentCenter; + if (zoomCenter) { + zoomPoint = zoomCenter; + } else if (this.#centerPoint) { + zoomPoint = new Point(this.#centerPoint.lng, -this.#centerPoint.lat); + } + + // Calculate new viewBox + const newX = zoomPoint.lng - newWidthDegrees / 2; + const newY = zoomPoint.lat - newHeightDegrees / 2; + + // Update center point + this.#centerPoint = new Point(newX + newWidthDegrees / 2, -(newY + newHeightDegrees / 2)); + + // Apply new viewBox + this.svg.setAttribute("viewBox", `${newX} ${newY} ${newWidthDegrees} ${newHeightDegrees}`); + + // Clamp to bounds + MapEl.#clampViewBox(this.svg, this.#fullBounds); + } + + /** + * Calculate min/max ranges for statistical area values + * @param {StatisticalArea[]} statAreas + */ + #calculateStatAreaRanges(statAreas) { + this.#statAreaRanges = {}; + + // Calculate ranges for each parameter type + const parameters = [ + AreaColorParameter.foreignSpeakers, + AreaColorParameter.unemploymentRate, + AreaColorParameter.averageIncome, + AreaColorParameter.higherEducation, + ]; + + for (const param of parameters) { + const values = statAreas.map((area) => MapEl.#getStatisticalAreaValue(area, param)); + const min = Math.min(...values); + const max = Math.max(...values); + this.#statAreaRanges[param] = { max, min }; + } + } + + /** * Initialize map with empty content * @param {District[]} districts * @param {Collection} coastLine @@ -125,11 +242,14 @@ export class MapEl { * @param {TrainTracks[]} tracks * @param {TrainStation[]} stations * @param {House[]} houses + * @param {StatisticalArea[]} statAreas * @param {string} colorParameter * @returns {SVGSVGElement} */ - initialize(districts, coastLine, mainRoads, tracks, stations, houses, colorParameter) { + initialize(districts, coastLine, mainRoads, tracks, stations, houses, statAreas, colorParameter) { this.#houses = houses; + this.#statAreas = statAreas; + this.#calculateStatAreaRanges(statAreas); this.#setInitialViewBox(District.bounds(districts)); const transformGroup = Svg.g( new SvgOptions({ @@ -139,6 +259,22 @@ export class MapEl { new SvgOptions({ attributes: { "pointer-events": "none", + }, + children: [ + ...MapEl.#getStatisticalAreas( + statAreas, + this.#areaColorParameter, + this.#statAreaRanges, + ), + ...MapEl.#getStatisticalAreaLabels(statAreas), + ], + id: "statistical-areas", + }), + ), + Svg.g( + new SvgOptions({ + attributes: { + "pointer-events": "none", "stroke-width": "0.0005", }, children: [...MapEl.#getCoastLine(coastLine), ...MapEl.#getRoads(mainRoads)], @@ -306,10 +442,10 @@ export class MapEl { } /** - * Create an SVG element + * Create an SVG element with panning and zoom controls * @param {SVGSVGElement} svg */ - #enablePanning(svg) { + #enableControls(svg) { let isDragging = false; /** @type {number|null} */ let pointerId = null; @@ -329,83 +465,176 @@ export class MapEl { /** @type {SVGRect} */ let startViewBox; + // Pinch zoom variables + /** @type {Map<number, {clientX: number, clientY: number}>} */ + const pointers = new Map(); + let initialDistance = 0; + let isPinching = false; + svg.addEventListener("pointerdown", (e) => { - if (e.pointerType === "touch") return; - isDragging = true; - pointerId = e.pointerId; - svg.setPointerCapture(pointerId); - startX = lastX = e.clientX; - startY = lastY = e.clientY; - lastTime = performance.now(); - vx = vy = 0; - startViewBox = svg.viewBox.baseVal; - svg.setAttribute("style", "cursor: grabbing;"); + if (e.pointerType === "mouse" && e.button !== 0) return; // Only left mouse button + + pointers.set(e.pointerId, { clientX: e.clientX, clientY: e.clientY }); + + if (pointers.size === 2) { + // Start pinch gesture + isPinching = true; + isDragging = false; + const [p1, p2] = Array.from(pointers.values()); + initialDistance = Math.hypot(p2.clientX - p1.clientX, p2.clientY - p1.clientY); + } else if (!isPinching) { + isDragging = true; + pointerId = e.pointerId; + svg.setPointerCapture(pointerId); + startX = lastX = e.clientX; + startY = lastY = e.clientY; + lastTime = performance.now(); + vx = vy = 0; + startViewBox = svg.viewBox.baseVal; + svg.setAttribute("style", "cursor: grabbing;"); + } e.preventDefault(); }); svg.addEventListener("pointermove", (e) => { - if (!isDragging || e.pointerId !== pointerId) return; - const now = performance.now(); - const dt = now - lastTime; - const dx = e.clientX - lastX; - const dy = e.clientY - lastY; + pointers.set(e.pointerId, { clientX: e.clientX, clientY: e.clientY }); - if (dt > 0) { + if (isPinching && pointers.size === 2) { + // Handle pinch zoom + const [p1, p2] = Array.from(pointers.values()); + const currentDistance = Math.hypot(p2.clientX - p1.clientX, p2.clientY - p1.clientY); + + if (initialDistance > 0) { + const scaleFactor = currentDistance / initialDistance; + + // Calculate center point between the two pointers in SVG coordinates + const centerX = (p1.clientX + p2.clientX) / 2; + const centerY = (p1.clientY + p2.clientY) / 2; + + const ctm = svg.getScreenCTM(); + if (ctm) { + const point = svg.createSVGPoint(); + point.x = centerX; + point.y = centerY; + const svgPoint = point.matrixTransform(ctm.inverse()); + + this.#zoom(scaleFactor, new Point(svgPoint.x, svgPoint.y)); + initialDistance = currentDistance; + } + } + } else if (isDragging && e.pointerId === pointerId) { + const now = performance.now(); + const dt = now - lastTime; + const dx = e.clientX - lastX; + const dy = e.clientY - lastY; + + if (dt > 0) { + 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; + } + + const totalDx = e.clientX - startX; + const totalDy = e.clientY - startY; 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; + const svgTotalDx = totalDx * ctm.a + totalDy * ctm.c; + const svgTotalDy = totalDx * ctm.b + totalDy * ctm.d; + + const newMinX = startViewBox.x - svgTotalDx; + const newMinY = startViewBox.y - svgTotalDy; + svg.setAttribute( + "viewBox", + `${newMinX} ${newMinY} ${startViewBox.width} ${startViewBox.height}`, + ); + + MapEl.#clampViewBox(svg, this.#fullBounds); + + lastX = e.clientX; + lastY = e.clientY; + lastTime = now; } + e.preventDefault(); + }); + + svg.addEventListener("pointerup", (e) => { + pointers.delete(e.pointerId); - const totalDx = e.clientX - startX; - const totalDy = e.clientY - startY; - const ctm = svg.getScreenCTM()?.inverse(); - if (ctm === undefined) { - throw new Error("Unexpected"); + if (isPinching && pointers.size < 2) { + isPinching = false; + initialDistance = 0; } - const svgTotalDx = totalDx * ctm.a + totalDy * ctm.c; - const svgTotalDy = totalDx * ctm.b + totalDy * ctm.d; + if (e.pointerId === pointerId) { + isDragging = false; + pointerId = null; + this.svg.releasePointerCapture(e.pointerId); + this.svg.setAttribute("style", "cursor: grab;"); - const newMinX = startViewBox.x - svgTotalDx; - const newMinY = startViewBox.y - svgTotalDy; - svg.setAttribute( - "viewBox", - `${newMinX} ${newMinY} ${startViewBox.width} ${startViewBox.height}`, - ); + const speed = Math.hypot(vx, vy); + if (speed > PanningConfig.DEFAULT_SPEED_THRESHOLD) { + this.#startInertia(this.svg, vx, vy); + } + } + }); - MapEl.#clampViewBox(svg, this.#fullBounds); + svg.addEventListener("pointercancel", (e) => { + pointers.delete(e.pointerId); - lastX = e.clientX; - lastY = e.clientY; - lastTime = now; - e.preventDefault(); + if (isPinching && pointers.size < 2) { + isPinching = false; + initialDistance = 0; + } + + if (e.pointerId === pointerId) { + isDragging = false; + pointerId = null; + this.svg.releasePointerCapture(e.pointerId); + this.svg.setAttribute("style", "cursor: grab;"); + } }); - svg.addEventListener("pointerup", (e) => { - if (e.pointerId !== pointerId) return; - isDragging = false; - pointerId = null; - this.svg.releasePointerCapture(e.pointerId); - this.svg.setAttribute("style", "cursor: grab;"); - - const speed = Math.hypot(vx, vy); - if (speed > PanningConfig.DEFAULT_SPEED_THRESHOLD) { - this.#startInertia(this.svg, vx, vy); + // Mouse wheel zoom + svg.addEventListener("wheel", (e) => { + e.preventDefault(); + + const delta = -e.deltaY; + const scaleFactor = delta > 0 ? 0.8 : 1.25; + + const ctm = svg.getScreenCTM(); + if (ctm) { + const point = svg.createSVGPoint(); + point.x = e.clientX; + point.y = e.clientY; + const svgPoint = point.matrixTransform(ctm.inverse()); + + this.#zoom(scaleFactor, new Point(svgPoint.x, svgPoint.y)); } }); - svg.addEventListener("pointercancel", (e) => { - if (e.pointerId !== pointerId) return; - isDragging = false; - pointerId = null; - this.svg.releasePointerCapture(e.pointerId); - this.svg.setAttribute("style", "cursor: grab;"); + // Double-click zoom + svg.addEventListener("dblclick", (e) => { + e.preventDefault(); + + const ctm = svg.getScreenCTM(); + if (ctm) { + const point = svg.createSVGPoint(); + point.x = e.clientX; + point.y = e.clientY; + const svgPoint = point.matrixTransform(ctm.inverse()); + + const scaleFactor = e.shiftKey ? 0.5 : 2; // Zoom out with shift, zoom in without + this.#zoom(scaleFactor, new Point(svgPoint.x, svgPoint.y)); + } }); } @@ -445,7 +674,7 @@ export class MapEl { circle.setAttribute("stroke-width", "0.001"); if (!this.#persistentModal && this.#onHouseHover) { - this.#modalTimer = setTimeout(() => { + this.#modalTimer = window.setTimeout(() => { if (this.#onHouseHover) { this.#onHouseHover(house.id, true); } @@ -474,7 +703,7 @@ export class MapEl { new SvgOptions({ attributes: { "data-id": district.name, - fill: "rgba(100, 150, 255, 0.2)", + fill: "none", // Changed from semi-transparent blue to transparent "pointer-events": "stroke", stroke: "rgba(85, 85, 85, 1)", "stroke-width": "0.001", @@ -483,13 +712,11 @@ export class MapEl { ); poly.addEventListener("mouseenter", () => { - 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.setAttribute("fill", "rgba(100, 150, 255, 0.2)"); poly.setAttribute("stroke", "rgba(85, 85, 85, 1)"); poly.setAttribute("stroke-width", "0.001"); }); @@ -523,6 +750,128 @@ export class MapEl { } /** + * Set statistical areas data and render polygons + * @param {StatisticalArea[]} statAreas + * @param {string} areaColorParameter + * @param {Object} ranges + */ + static #getStatisticalAreas(statAreas, areaColorParameter, ranges) { + return statAreas.map((area) => { + const color = MapEl.#getStatisticalAreaColor(area, areaColorParameter, ranges); + const value = MapEl.#getStatisticalAreaValue(area, areaColorParameter); + + const poly = Svg.polygon( + area.polygon.simplify(30), + new SvgOptions({ + attributes: { + "data-id": area.id, + fill: color, + "pointer-events": "none", + stroke: "rgba(0, 0, 0, 0.3)", + "stroke-width": "0.0003", + }, + }), + ); + + // Add tooltip with area name and value + const tooltipText = `${area.properties.nimi}\n${MapEl.#getStatisticalAreaDisplayText(areaColorParameter, value)}`; + const title = Svg.title(tooltipText); + poly.appendChild(title); + + return poly; + }); + } + + /** + * Set statistical area labels + * @param {StatisticalArea[]} statAreas + */ + static #getStatisticalAreaLabels(statAreas) { + return statAreas.map((area) => { + const center = area.centroid; + return Svg.text( + center, + area.properties.nimi, + new SvgOptions({ + attributes: { + "data-id": area.id, + "dominant-baseline": "middle", + "font-size": "0.0025", // Half of district font size + "pointer-events": "none", + "text-anchor": "middle", + transform: `translate(${center.lng}, ${center.lat}) scale(1, -1) translate(${-center.lng}, ${-center.lat})`, + }, + }), + ); + }); + } + + /** + * Get color for statistical area based on parameter value + * @param {StatisticalArea} area + * @param {string} areaColorParameter + * @param {Object} ranges + * @returns {string} + */ + static #getStatisticalAreaColor(area, areaColorParameter, ranges) { + if (areaColorParameter === AreaColorParameter.none) { + return "rgba(0, 0, 0, 0)"; // Transparent + } + + const value = MapEl.#getStatisticalAreaValue(area, areaColorParameter); + const range = ranges[areaColorParameter]; + const normalized = range ? MapMath.normalize(value, range.min, range.max) : 0; + return Color.get("fall", normalized, true); + } + + /** + * Get value for statistical area based on parameter + * @param {StatisticalArea} area + * @param {string} areaColorParameter + * @returns {number} + */ + static #getStatisticalAreaValue(area, areaColorParameter) { + const props = area.properties; + + switch (areaColorParameter) { + case AreaColorParameter.foreignSpeakers: + return props.vr_kiel_vier / props.vr_vakiy; + case AreaColorParameter.unemploymentRate: + return props.tp_tyotaste; + case AreaColorParameter.averageIncome: + return props.tu_kesk; + case AreaColorParameter.higherEducation: + return props.ko_yl_kork / props.ko_25_; + default: + return 0; + } + } + + /** + * Get display text for statistical area tooltip + * @param {string} areaColorParameter + * @param {number} value + * @returns {string} + */ + static #getStatisticalAreaDisplayText(areaColorParameter, value) { + if (!(typeof value === "number")) { + return "NaN"; + } + switch (areaColorParameter) { + case AreaColorParameter.foreignSpeakers: + return `Foreign speakers: ${(value * 100).toFixed(1)}%`; + case AreaColorParameter.unemploymentRate: + return `Unemployment rate: ${value.toFixed(1)}%`; + case AreaColorParameter.averageIncome: + return `Average income: ${Math.round(value).toLocaleString()} €`; + case AreaColorParameter.higherEducation: + return `Higher education: ${(value * 100).toFixed(1)}%`; + default: + return ""; + } + } + + /** * @param {Collection} roads */ static #getRoads(roads) { @@ -591,6 +940,36 @@ export class MapEl { } /** + * Update statistical area colors based on current area color parameter + * @param {string} areaColorParameter + */ + setAreaColorParameter(areaColorParameter) { + this.#areaColorParameter = areaColorParameter; + + const statAreaPolygons = this.svg.querySelectorAll("#statistical-areas polygon"); + statAreaPolygons.forEach((polygon) => { + const areaId = polygon.getAttribute("data-id"); + const area = this.#statAreas.find((a) => a.id === areaId); + if (area) { + const color = MapEl.#getStatisticalAreaColor( + area, + areaColorParameter, + this.#statAreaRanges, + ); + polygon.setAttribute("fill", color); + + // Update tooltip + const value = MapEl.#getStatisticalAreaValue(area, areaColorParameter); + const tooltipText = `${area.properties.nimi}\n${MapEl.#getStatisticalAreaDisplayText(areaColorParameter, value)}`; + const title = polygon.querySelector("title"); + if (title) { + title.textContent = tooltipText; + } + } + }); + } + + /** * Update house visibility based on filtered house IDs * @param {string[]} filteredHouseIds */ @@ -654,27 +1033,194 @@ export class MapEl { } const normalized = MapMath.normalize(value, min, max); - return MapEl.#gradientColor(normalized); - } - - /** - * Calculate gradient color based on normalized value - * @param {number} normalized - * @returns {string} color - */ - static #gradientColor(normalized) { - if (normalized < 0.5) { - const t = normalized * 2; - 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(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)`; + return Color.get("ocean", normalized); + } +} + +/** + * Static class for perceptually uniform colormaps based on CMasher + * Provides color mapping from value [0,1] to RGBA colors + */ +export class Color { + /** + * Get color from specified colormap + * @param {string} colormap - Name of colormap ('fall', 'ocean', 'bubblegum', 'lilac') + * @param {number} value - Normalized value between 0 and 1 + * @param {boolean} [reverse=false] - Reverse the colormap + * @returns {string} RGBA color string + */ + static get(colormap, value, reverse = false) { + if (Number.isNaN(value) || value > 1 || value < 0) { + throw new Error(`Input must be a number between [0,1] ${value}`); } + const normalizedT = reverse ? 1 - value : value; + + switch (colormap.toLowerCase()) { + case "fall": + return Color.#fall(normalizedT); + case "ocean": + return Color.#ocean(normalizedT); + case "bubblegum": + return Color.#bubblegum(normalizedT); + case "lilac": + return Color.#lilac(normalizedT); + default: + throw new Error(`Unknown colormap: ${colormap}`); + } + } + + /** + * Fall colormap - warm sequential colors + * Based on CMasher fall colormap: warm colors from black through reds to yellow + * @param {number} t - Normalized value [0,1] + * @returns {string} RGBA color + */ + static #fall(t) { + // CMasher fall: black -> dark red -> red -> orange -> yellow -> white + const colors = [ + [0.0, 0.0, 0.0], // black + [0.1961, 0.0275, 0.0118], // dark red + [0.5176, 0.102, 0.0431], // medium red + [0.8235, 0.251, 0.0784], // bright red + [0.9647, 0.5216, 0.149], // orange + [0.9961, 0.7686, 0.3098], // yellow-orange + [0.9961, 0.898, 0.5451], // light yellow + [0.9882, 0.9608, 0.8157], // very light yellow/white + ]; + + return Color.#interpolateColor(t, colors); + } + + /** + * Ocean colormap - cool sequential colors + * Based on CMasher ocean colormap: dark blue to light blue/cyan + * @param {number} t - Normalized value [0,1] + * @returns {string} RGBA color + */ + static #ocean(t) { + // CMasher ocean: black -> dark blue -> blue -> cyan -> light cyan + const colors = [ + [0.0, 0.0, 0.0], // black + [0.0314, 0.0706, 0.1647], // dark blue + [0.0627, 0.1843, 0.3843], // medium blue + [0.0941, 0.3608, 0.6784], // blue + [0.1098, 0.4824, 0.8627], // bright blue + [0.3255, 0.6784, 0.949], // light blue + [0.6, 0.8471, 0.9882], // cyan + [0.851, 0.949, 0.9961], // light cyan + ]; + + return Color.#interpolateColor(t, colors); + } + + /** + * Bubblegum colormap - pink/purple sequential colors + * Based on CMasher bubblegum colormap: dark purple to light pink + * @param {number} t - Normalized value [0,1] + * @returns {string} RGBA color + */ + static #bubblegum(t) { + // CMasher bubblegum: black -> dark purple -> purple -> pink -> light pink + const colors = [ + [0.0, 0.0, 0.0], // black + [0.1412, 0.0392, 0.1804], // dark purple + [0.2824, 0.0784, 0.3608], // purple + [0.4235, 0.1176, 0.5412], // medium purple + [0.6196, 0.1882, 0.6745], // pink-purple + [0.8118, 0.3373, 0.7725], // pink + [0.9373, 0.5765, 0.8431], // light pink + [0.9882, 0.8, 0.898], // very light pink + ]; + + return Color.#interpolateColor(t, colors); + } + + /** + * Lilac colormap - purple sequential colors + * Based on CMasher lilac colormap: dark purple to light lilac + * @param {number} t - Normalized value [0,1] + * @returns {string} RGBA color + */ + static #lilac(t) { + // CMasher lilac: black -> dark purple -> purple -> lilac -> light lilac + const colors = [ + [0.0, 0.0, 0.0], // black + [0.0902, 0.0588, 0.1882], // dark purple + [0.1725, 0.1098, 0.349], // medium dark purple + [0.2941, 0.1725, 0.5176], // purple + [0.4471, 0.2667, 0.6588], // lilac-purple + [0.6235, 0.4078, 0.7725], // lilac + [0.7843, 0.5882, 0.8627], // light lilac + [0.9176, 0.7686, 0.9333], // very light lilac + ]; + + return Color.#interpolateColor(t, colors); + } + + /** + * Interpolate between color points + * @param {number} t - Normalized value [0,1] + * @param {number[][]} colors - Array of RGB colors (0-1 range) + * @returns {string} RGBA color string + */ + static #interpolateColor(t, colors) { + const n = colors.length - 1; + const segment = t * n; + const index = Math.floor(segment); + const localT = segment - index; + + if (index >= n) { + // At or beyond the last color + const [r, g, b] = colors[n]; + return `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, 1)`; + } + + // Linear interpolation between two colors + const [r1, g1, b1] = colors[index]; + const [r2, g2, b2] = colors[index + 1]; + + const r = r1 + (r2 - r1) * localT; + const g = g1 + (g2 - g1) * localT; + const b = b1 + (b2 - b1) * localT; + + return `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, 1)`; + } + + /** + * Get all available colormap names + * @returns {string[]} Array of colormap names + */ + static getColormapNames() { + return ["fall", "ocean", "bubblegum", "lilac"]; + } + + /** + * Generate a color scale for testing/visualization + * @param {string} colormap - Name of colormap + * @param {number} steps - Number of steps in the scale + * @param {boolean} [reverse=false] - Reverse the colormap + * @returns {string[]} Array of RGBA colors + */ + static generateColorScale(colormap, steps = 10, reverse = false) { + const colors = []; + for (let i = 0; i < steps; i++) { + const t = i / (steps - 1); + colors.push(Color.get(colormap, t, reverse)); + } + return colors; + } + + /** + * Get color with custom alpha value + * @param {string} colormap - Name of colormap + * @param {number} value - Normalized value between 0 and 1 + * @param {number} alpha - Alpha value between 0 and 1 + * @param {boolean} [reverse=false] - Reverse the colormap + * @returns {string} RGBA color string + */ + static getColorWithAlpha(colormap, value, alpha, reverse = false) { + const color = Color.get(colormap, value, reverse); + // Replace the alpha value in the rgba string + return color.replace(/[\d.]+\)$/, `${alpha})`); } } diff --git a/app/models.js b/app/models.js index d2c5b55..b100ac8 100644 --- a/app/models.js +++ b/app/models.js @@ -73,11 +73,127 @@ import { Bounds, Collection, Feature, Geometry, LineString, Point, Polygon } fro */ /** - * API response structure - * @typedef {Object} ApiResponse - * @property {number} total_rows - * @property {number} offset - * @property {Array<{doc: HouseJson}>} rows + * Statistical Area Properties JSON structure + * @typedef {Object} StatisticalAreaPropertiesJson + * @property {number} id + * @property {number} osa_alueid + * @property {string} nimi + * @property {string} namn + * @property {number} kokotun + * @property {number} vuosi + * @property {number} vr_vakiy + * @property {number} vr_0_2 + * @property {number} vr_3_6 + * @property {number} vr_7_12 + * @property {number} vr_13_15 + * @property {number} vr_16_17 + * @property {number} vr_18_19 + * @property {number} vr_20_24 + * @property {number} vr_25_29 + * @property {number} vr_30_34 + * @property {number} vr_35_39 + * @property {number} vr_40_44 + * @property {number} vr_45_49 + * @property {number} vr_50_54 + * @property {number} vr_55_59 + * @property {number} vr_60_64 + * @property {number} vr_65_69 + * @property {number} vr_70_74 + * @property {number} vr_75_79 + * @property {number} vr_80_84 + * @property {number} vr_85_ + * @property {number} vr_kiel_su_sa + * @property {number} vr_kiel_ru + * @property {number} vr_kiel_vier + * @property {number} vm_synt + * @property {number} vm_kuol + * @property {number} vm_mu_tulo + * @property {number} vm_mu_lahto + * @property {number} vm_s_mu_tulo + * @property {number} vm_s_mu_lahto + * @property {number} ko_25_ + * @property {number} ko_tut_yht + * @property {number} ko_toinen + * @property {number} ko_al_kork + * @property {number} ko_yl_kork + * @property {number} ko_perus + * @property {number} tu_kesk + * @property {number} tu_ask_lkm + * @property {number} tu_pien + * @property {number} tu_med + * @property {string} ap_ask_lkm + * @property {string} ap_yhd_ask + * @property {string} ap_lper + * @property {number} ap_lper_l1 + * @property {number} ap_lper_l2 + * @property {number} ap_lper_l3 + * @property {number} ap_lper_l4 + * @property {string} ap_per_yht + * @property {number} ap_yhd_vanh + * @property {string} ra_rak + * @property {string} ra_rak_ker + * @property {string} ra_asrak + * @property {number} ra_as + * @property {number} ra_as_om + * @property {number} ra_as_vu + * @property {number} ra_as_asoik + * @property {number} ra_as_muu + * @property {number} ra_pt_as + * @property {number} ra_kt_as + * @property {number} ra_hu_1 + * @property {number} ra_hu_2 + * @property {number} ra_hu_3 + * @property {number} ra_hu_4 + * @property {number} ra_hu_5 + * @property {number} ra_hu_6 + * @property {number} ra_hu_muu + * @property {number} tp_tyopy + * @property {number} tp_a + * @property {number} tp_b + * @property {number} tp_c + * @property {number} tp_d + * @property {number} tp_e + * @property {number} tp_f + * @property {number} tp_g + * @property {number} tp_h + * @property {number} tp_i + * @property {number} tp_j + * @property {number} tp_k + * @property {number} tp_l + * @property {number} tp_m + * @property {number} tp_n + * @property {number} tp_o + * @property {number} tp_p + * @property {number} tp_q + * @property {number} tp_r + * @property {number} tp_s + * @property {number} tp_t + * @property {number} tp_u + * @property {number} tp_x + * @property {number} tp_asuky + * @property {number} tp_ + * @property {number} tp_tyol + * @property {number} tp_tyot + * @property {number} tp_tyov_ulk + * @property {number} tp_0_14 + * @property {number} tp_opisk_var + * @property {number} tp_elak + * @property {number} tp_tyotaste + * @property {number|null} vr_enn_2037 + * @property {number} vr_enn + * @property {string|null} paivitetty_tietopalveluun + */ + +/** + * Statistical Area JSON from CouchDB + * @typedef {Object} StatisticalAreaJson + * @property {string} _id + * @property {string} _rev + * @property {string} downloaded_at + * @property {Object} geometry + * @property {string} layer + * @property {StatisticalAreaPropertiesJson} properties + * @property {string} type */ export class PriceUpdate { @@ -155,6 +271,61 @@ export class Geospatial { } /** + * Represents a statistical area with demographic and housing data + */ +export class StatisticalArea { + /** + * @param {string} id + * @param {Polygon} polygon + * @param {StatisticalAreaPropertiesJson} properties + */ + constructor(id, polygon, properties) { + this.id = id; + this.polygon = polygon; + this.properties = properties; + } + + /** + * @param {Feature} feature + * @returns {StatisticalArea} + */ + static fromFeature(feature) { + const geometry = feature.geometry; + if (!(geometry instanceof Polygon)) { + throw new Error(`Invalid statistical area feature data ${geometry}`); + } + + return new StatisticalArea(feature.id, geometry, feature.properties); + } + + /** + * Convert Collection to StatisticalArea[] + * @param {Collection} collection + * @returns {StatisticalArea[]} + */ + static fromCollection(collection) { + return collection.features.map(StatisticalArea.fromFeature); + } + + /** + * Check if point is within this statistical area + * @param {Point} point + * @returns {boolean} + */ + contains(point) { + return this.polygon.within(point) || this.polygon.intersects(point); + } + + /** + * Get the centroid of the statistical area + * @returns {Point} + */ + get centroid() { + return this.polygon.centroid(); + } +} + +/** * Represents a geographic district with name and polygon */ export class District { @@ -506,15 +677,19 @@ export class DataProvider { */ static async getCollectionFromCouch(layerName) { // Use CouchDB view to get all features for the layer - const viewUrl = `${DataProvider.couchBaseUrl}/${DataProvider.wfsDbName}/_design/layers/_view/by_layer`; + const viewUrl = new URL( + `/${DataProvider.wfsDbName}/_design/layers/_view/by_layer`, + DataProvider.couchBaseUrl, + ); const params = new URLSearchParams({ - // biome-ignore lint/style/useNamingConvention: header names are not optional + // biome-ignore lint/style/useNamingConvention: url search params include_docs: "true", key: JSON.stringify(layerName), }); const response = await fetch(`${viewUrl}?${params}`, { headers: new Headers({ accept: "application/json" }), + method: "GET", mode: "cors", }); @@ -562,11 +737,20 @@ export class DataProvider { return TrainTracks.fromCollection(collection); } + /** @returns {Promise<StatisticalArea[]>} */ + static async getStatisticalAreas() { + const collection = await DataProvider.getCollectionFromCouch("Aluesarjat_avainluvut_2024"); + return StatisticalArea.fromCollection(collection); + } + /** @returns {Promise<House[]>} */ static async getHouses() { try { const response = await fetch( - `${DataProvider.couchBaseUrl}/${DataProvider.housesDbName}/_all_docs?include_docs=true`, + new URL( + `/${DataProvider.housesDbName}/_all_docs?include_docs=true`, + DataProvider.couchBaseUrl, + ), { headers: new Headers({ accept: "application/json" }), mode: "cors", diff --git a/download.js b/download.js index 90a7d81..6a1e67e 100644 --- a/download.js +++ b/download.js @@ -1,10 +1,15 @@ -import crypto from "crypto"; -import fs from "fs"; -import path from "path"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; const couchUsername = process.env.COUCHDB_USERNAME; const couchPassword = process.env.COUCHDB_PASSWORD; +/** + * Generates the Basic Auth header for CouchDB using environment variables. + * @returns {string} The Basic Auth header string. + * @throws {Error} If CouchDB credentials are not set. + */ function getAuthHeader() { if (!couchUsername || !couchPassword) { throw new Error("CouchDB credentials not set in environment variables"); @@ -13,7 +18,6 @@ function getAuthHeader() { return `Basic ${auth}`; } -// === CONFIG === const baseUrl = "https://kartta.hel.fi/ws/geoserver/avoindata/wfs"; const layers = [ "Aluesarjat_avainluvut_2024", @@ -41,6 +45,10 @@ if (!fs.existsSync(outputDir)) { fs.mkdirSync(outputDir, { recursive: true }); } +/** + * Creates headers for CouchDB requests, including authorization. + * @returns {Headers} The Headers object for fetch requests. + */ function getHeaders() { return new Headers({ // biome-ignore lint/style/useNamingConvention: database @@ -49,7 +57,11 @@ function getHeaders() { }); } -// === COUCHDB HELPERS === +/** + * Creates the CouchDB database if it doesn't exist. + * @returns {Promise<void>} + * @throws {Error} If database creation fails (other than already exists). + */ async function createDatabase() { const url = `${couchUrl}/${dbName}`; const res = await fetch(url, { @@ -58,11 +70,16 @@ async function createDatabase() { }); if (res.ok || res.status === 412) { console.log(`Database ${dbName} ready.`); + return; } else { throw new Error(await res.text()); } } +/** + * Ensures the design documents (views) exist in the database, creating or updating as needed. + * @returns {Promise<void>} + */ async function ensureDesignDocs() { const designDoc = { _id: "_design/layers", @@ -87,6 +104,7 @@ async function ensureDesignDocs() { method: "PUT", }); console.log("Created design document: layers/by_layer"); + return; } else if (res.ok) { const existing = await res.json(); designDoc._rev = existing._rev; @@ -96,10 +114,18 @@ async function ensureDesignDocs() { method: "PUT", }); console.log("Updated design document"); + return; } + // If neither, implicitly return void, but log unexpected status + console.warn(`Unexpected status when ensuring design docs: ${res.status}`); } -// === DOWNLOAD === +/** + * Downloads a GeoJSON layer from the WFS service. + * @param {string} layer - The name of the layer to download. + * @returns {Promise<object>} The parsed GeoJSON object. + * @throws {Error} If the fetch fails. + */ async function downloadLayer(layer) { const url = `${baseUrl}?service=WFS&version=2.0.0&request=GetFeature&typeName=avoindata:${layer}&outputFormat=json&srsname=EPSG:4326`; const res = await fetch(url); @@ -108,13 +134,26 @@ async function downloadLayer(layer) { return response; } +/** + * Saves GeoJSON data to a local file. + * Note: This function is defined but not currently used in the script. It could be called in processLayer if local saving is desired. + * @param {string} layer - The layer name for the file. + * @param {object} data - The GeoJSON data to save. + * @returns {void} + */ function saveToFile(layer, data) { const filePath = path.join(outputDir, `${layer}.geojson`); fs.writeFileSync(filePath, JSON.stringify(data, null, "\t")); console.log(`Saved: ${layer}.geojson`); } -// === UPLOAD METADATA === +/** + * Uploads or updates metadata for a layer in CouchDB. + * @param {string} layer - The layer name. + * @param {number} featureCount - The number of features in the layer. + * @returns {Promise<void>} + * @throws {Error} If the upload fails. + */ async function uploadLayerMetadata(layer, featureCount) { const docId = `layer_metadata:${layer}`; @@ -142,9 +181,15 @@ async function uploadLayerMetadata(layer, featureCount) { }); if (!putRes.ok) throw new Error(await putRes.text()); console.log(`Metadata updated: ${layer} (${featureCount} features)`); + return; } -// === UPLOAD SINGLE FEATURE (with deduplication) === +/** + * Uploads a single feature document to CouchDB, with deduplication check. + * @param {object} doc - The feature document to upload. + * @returns {Promise<boolean>} True if uploaded/updated, false if skipped (no changes). + * @throws {Error} If the upload fails. + */ async function uploadFeature(doc) { const url = `${couchUrl}/${dbName}/${doc._id}`; const getRes = await fetch(url, { headers: getHeaders() }); @@ -165,15 +210,20 @@ async function uploadFeature(doc) { method: "PUT", }); - return putRes.ok; + if (!putRes.ok) throw new Error(await putRes.text()); + return true; // uploaded or updated } -// === PROCESS LAYER === +/** + * Processes a single layer: downloads GeoJSON, uploads features with dedup, and updates metadata. + * @param {string} layer - The layer to process. + * @returns {Promise<{uploaded: number, skipped: number}>} Counts of uploaded and skipped features. + * @throws {Error} If download or uploads fail. + */ async function processLayer(layer) { const geojson = await downloadLayer(layer); if (!geojson || !geojson.features) { - console.warn(`No features in ${layer} ${geojson}`); - process.exit(1); + throw new Error(`No features in ${layer}: ${JSON.stringify(geojson)}`); } let uploaded = 0; @@ -204,9 +254,13 @@ async function processLayer(layer) { await uploadLayerMetadata(layer, geojson.features.length); console.log(`Done: ${layer} | Uploaded: ${uploaded} | Skipped: ${skipped}`); + return { skipped, uploaded }; } -// === MAIN === +/** + * Main entry point: sets up database, processes all layers. + * @returns {Promise<void>} + */ async function main() { await createDatabase(); await ensureDesignDocs(); @@ -218,6 +272,7 @@ async function main() { } console.log("All layers processed."); + return; } if (process.argv[1] === new URL(import.meta.url).pathname) { diff --git a/jsconfig.json b/jsconfig.json index 1d6d924..d588553 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -11,8 +11,10 @@ "noUnusedLocals": true, "noUnusedParameters": true, "paths": { + "components": ["./app/components.js"], "dom": ["./app/dom.js"], "geom": ["./app/geometry.js"], + "main": ["./app/main.js"], "models": ["./app/models.js"], "svg": ["./app/svg.js"] }, |
