aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorPetri Hienonen <petri.hienonen@gmail.com>2025-11-09 22:59:02 +0200
committerPetri Hienonen <petri.hienonen@gmail.com>2025-11-11 15:35:03 +0200
commit909773f9d253c61183cc1f9f6193656957946be5 (patch)
tree136075e1946accedda0530dd25940b8931408c5a
parentbe7ec90b500ac68e053f2b58feb085247ef95817 (diff)
downloadhousing-909773f9d253c61183cc1f9f6193656957946be5.tar.zst
Add statistical areas
Diffstat (limited to '')
-rw-r--r--README.adoc9
-rw-r--r--app/components.js864
-rw-r--r--app/dom.js410
-rw-r--r--app/index.html1
-rw-r--r--app/main.js377
-rw-r--r--app/map.js714
-rw-r--r--app/models.js200
-rw-r--r--download.js81
-rw-r--r--jsconfig.json2
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();
+ }
+}
diff --git a/app/dom.js b/app/dom.js
index ab43570..508636a 100644
--- a/app/dom.js
+++ b/app/dom.js
@@ -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"),
+ );
}
}
diff --git a/app/map.js b/app/map.js
index 2658df9..2259b60 100644
--- a/app/map.js
+++ b/app/map.js
@@ -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"]
},