aboutsummaryrefslogtreecommitdiffstats
path: root/app/main.js
diff options
context:
space:
mode:
authorPetri Hienonen <petri.hienonen@gmail.com>2025-11-08 22:05:01 +0200
committerPetri Hienonen <petri.hienonen@gmail.com>2025-11-08 22:05:01 +0200
commit277ffe2cab8c711427b979fbc057c7d04932398e (patch)
treecf14de72a58ac1c0712590cef7805518604cdb89 /app/main.js
parenta9a0070662c2494b37528d27d7420f3da33e749d (diff)
downloadhousing-277ffe2cab8c711427b979fbc057c7d04932398e.tar.zst
Update DOM
Diffstat (limited to 'app/main.js')
-rw-r--r--app/main.js560
1 files changed, 240 insertions, 320 deletions
diff --git a/app/main.js b/app/main.js
index b19d624..3c9b637 100644
--- a/app/main.js
+++ b/app/main.js
@@ -1,4 +1,4 @@
-import { Dom, DomOptions } from "dom";
+import { Dom, DomOptions, Widgets } from "dom";
import { ColorParameter, MapEl } from "map";
import {
DataProvider,
@@ -54,18 +54,25 @@ export class App {
this.#controls = App.buildControls(
this.#filters,
this.#weights,
- () => this.#applyFilters(),
+ () => {
+ 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.#updateMapHouseColors();
+ App.#recalculateScores(this.#houses, this.#weights);
+ this.#map?.setColorParameter(this.#colorParameter);
this.#updateStats();
},
(param) => {
this.#colorParameter = param;
- this.#updateMapHouseColors();
+ this.#map?.setColorParameter(this.#colorParameter);
},
);
@@ -157,6 +164,69 @@ export class App {
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",
@@ -171,275 +241,158 @@ export class App {
}),
);
- // Color parameter section
- const colorSection = Dom.div(
- new DomOptions({
- styles: {
- borderBottom: "1px solid #eee",
- paddingBottom: "1rem",
- },
- }),
- );
-
- const colorTitle = Dom.heading(
- 3,
- "Map Colors",
- new DomOptions({
- styles: {
- color: "#333",
- fontSize: "1.1rem",
- margin: "0 0 1rem 0",
- },
- }),
- );
-
- const colorGroup = Dom.div(
- new DomOptions({
- styles: { display: "flex", flexDirection: "column" },
- }),
- );
-
- const colorLabel = Dom.label(
- "color-parameter",
- "Color houses by",
- new DomOptions({
- styles: { fontSize: "0.85rem", fontWeight: "bold", marginBottom: "0.25rem" },
- }),
- );
-
- const colorSelect = Dom.select(
- (e) => {
- const target = /** @type {HTMLSelectElement} */ (e.target);
- onColorChange(target.value);
- },
- new DomOptions({
- id: "color-parameter",
- styles: {
- border: "1px solid #ddd",
- borderRadius: "4px",
- fontSize: "0.9rem",
- padding: "0.5rem",
- },
- }),
- );
-
- colorSelect.append(
- Dom.option(ColorParameter.price, "Price"),
- Dom.option(ColorParameter.score, "Score"),
- Dom.option(ColorParameter.year, "Construction Year"),
- Dom.option(ColorParameter.area, "Living Area"),
- );
-
- colorGroup.append(colorLabel, colorSelect);
- colorSection.append(colorTitle, colorGroup);
- controls.appendChild(colorSection);
-
- // Filter section
- const filterSection = Dom.div(
- new DomOptions({
- styles: {
- borderBottom: "1px solid #eee",
- paddingBottom: "1rem",
- },
- }),
- );
-
- const filterTitle = Dom.heading(
- 3,
- "Filters",
- new DomOptions({
- styles: {
- color: "#333",
- fontSize: "1.1rem",
- margin: "0 0 1rem 0",
- },
- }),
- );
-
- filterSection.appendChild(filterTitle);
-
- // Price filters in a row
- const priceRow = Dom.div(
- new DomOptions({
- styles: {
- display: "flex",
- gap: "0.5rem",
- },
- }),
- );
-
- const minPriceFilter = App.addNumberFilter("min-price", "Min price (€)", (v) => {
- filters.minPrice = v ?? 0;
- onFilterChange();
- });
-
- const maxPriceFilter = App.addNumberFilter("max-price", "Max price (€)", (v) => {
- filters.maxPrice = v ?? Number.POSITIVE_INFINITY;
- onFilterChange();
- });
-
- priceRow.append(minPriceFilter, maxPriceFilter);
-
- const yearFilter = App.addNumberFilter("min-year", "Min year", (v) => {
- filters.minYear = v ?? 0;
- onFilterChange();
- });
-
- const areaFilter = App.addNumberFilter("min-area", "Min area (m²)", (v) => {
- filters.minArea = v ?? 0;
- onFilterChange();
- });
-
- // District multi-select
- const districtGroup = Dom.div(
- new DomOptions({
- styles: { display: "flex", flexDirection: "column" },
- }),
- );
-
- const districtLabel = Dom.label(
- "district-select",
- "Districts",
- new DomOptions({
- styles: { fontSize: "0.85rem", fontWeight: "bold", marginBottom: "0.25rem" },
- }),
- );
-
- const districtSelect = 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" },
- id: "district-select",
- styles: {
- border: "1px solid #ddd",
- borderRadius: "4px",
- minHeight: "120px",
- padding: "0.5rem",
- },
- }),
- );
-
- districtGroup.append(districtLabel, districtSelect);
-
- filterSection.append(priceRow, yearFilter, areaFilter, districtGroup);
- controls.appendChild(filterSection);
-
- // Weights section
- const weightsSection = Dom.div(
- new DomOptions({
- styles: {
- borderBottom: "1px solid #eee",
- paddingBottom: "1rem",
- },
- }),
- );
-
- const weightsTitle = Dom.heading(
- 3,
- "Weights",
- new DomOptions({
- styles: {
- color: "#333",
- fontSize: "1.1rem",
- margin: "0 0 1rem 0",
- },
- }),
- );
+ controls.append(
+ Dom.div(
+ new DomOptions({
+ children: [
+ Dom.heading(
+ 3,
+ "Filters",
+ new DomOptions({
+ styles: {
+ color: "#333",
+ fontSize: "1.1rem",
+ margin: "0 0 1rem 0",
+ },
+ }),
+ ),
- weightsSection.appendChild(weightsTitle);
-
- // Create weight sliders
- const weightSliders = [
- Dom.slider("w-price", "Price weight", "price", weights.price, onWeightChange),
- Dom.slider(
- "w-market",
- "Market distance",
- "distanceMarket",
- weights.distanceMarket,
- onWeightChange,
- ),
- Dom.slider(
- "w-school",
- "School distance",
- "distanceSchool",
- weights.distanceSchool,
- onWeightChange,
- ),
- Dom.slider("w-crime", "Crime rate", "crimeRate", weights.crimeRate, onWeightChange),
- Dom.slider("w-safety", "Safety index", "safety", weights.safety, onWeightChange),
- Dom.slider("w-students", "S2 students", "s2Students", weights.s2Students, onWeightChange),
- Dom.slider(
- "w-railway",
- "Railway distance",
- "distanceRailway",
- weights.distanceRailway,
- onWeightChange,
+ Dom.div(
+ new DomOptions({
+ children: [
+ Widgets.addNumberFilter("min-price", "Min price (€)", (v) => {
+ filters.minPrice = v ?? 0;
+ onFilterChange();
+ }),
+
+ Widgets.addNumberFilter("max-price", "Max price (€)", (v) => {
+ filters.maxPrice = v ?? Number.POSITIVE_INFINITY;
+ onFilterChange();
+ }),
+ ],
+ id: "price-row",
+ styles: {
+ display: "flex",
+ gap: "0.5rem",
+ },
+ }),
+ ),
+ Widgets.addNumberFilter("min-year", "Min year", (v) => {
+ filters.minYear = v ?? 0;
+ onFilterChange();
+ }),
+
+ Widgets.addNumberFilter("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.slider(
- "w-year",
- "Construction year",
- "constructionYear",
- weights.constructionYear,
- onWeightChange,
+ 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",
+ },
+ }),
),
- ];
-
- weightsSection.append(...weightSliders);
- controls.appendChild(weightsSection);
-
- return controls;
- }
-
- /**
- * Create a number filter input
- * @param {string} id
- * @param {string} labelText
- * @param {(value: number | null) => void} onChange
- * @returns {HTMLElement}
- */
- static addNumberFilter(id, labelText, onChange) {
- const group = Dom.div(
- new DomOptions({
- styles: { display: "flex", flexDirection: "column", marginBottom: "0.75rem" },
- }),
- );
-
- const label = Dom.label(
- id,
- labelText,
- new DomOptions({
- styles: { fontSize: "0.85rem", fontWeight: "bold", marginBottom: "0.25rem" },
- }),
);
- const input = 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",
- },
- }),
- );
-
- group.append(label, input);
- return group;
+ return controls;
}
/**
@@ -460,16 +413,20 @@ export class App {
this.#modal?.remove();
// Create new modal
- this.#modal = Dom.buildModal(() => this.#hideModal());
- Object.assign(this.#modal.style, {
- left: "auto",
- maxHeight: "80vh",
- maxWidth: "400px",
- right: "20px",
- top: "50%",
- transform: "translateY(-50%)",
- width: "90%",
- });
+ this.#modal = Widgets.buildModal(
+ {
+ left: "auto",
+ maxHeight: "80vh",
+ maxWidth: "400px",
+ right: "20px",
+ top: "50%",
+ transform: "translateY(-50%)",
+ width: "90%",
+ },
+
+ () => this.#hideModal(),
+ );
+ Object.assign(this.#modal.style);
// Add hover grace period listeners
this.#modal.addEventListener("mouseenter", () => {
@@ -485,7 +442,7 @@ export class App {
}
});
- this.#modal.appendChild(Dom.buildHouseModalContent(house));
+ this.#modal.appendChild(Widgets.buildModalContent(house));
document.body.appendChild(this.#modal);
if (persistent) {
@@ -495,9 +452,6 @@ export class App {
}
}
- /**
- * Hide the modal
- */
#hideModal() {
this.#modal?.close();
this.#modal?.remove();
@@ -546,7 +500,7 @@ export class App {
}
// Populate district multi-select
- const districtOptions = App.renderDistrictOptions(this.#districts, this.#houses);
+ const districtOptions = App.#renderDistrictOptions(this.#districts, this.#houses);
const districtSelect = this.#controls.querySelector("#district-select");
if (districtSelect) {
districtSelect.append(...districtOptions);
@@ -559,62 +513,28 @@ export class App {
}
/**
- * Update house colors on map
- */
- #updateMapHouseColors() {
- if (this.#map) {
- this.#map.setColorParameter(this.#colorParameter);
- }
- }
-
- /**
* Render district options for multi-select
* @param {District[]} _districts
* @param {House[]} houses
* @returns {HTMLOptionElement[]}
*/
- static renderDistrictOptions(_districts, houses) {
+ 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));
}
- #applyFilters() {
- this.#filtered = App.applyFilters(this.#houses, this.#filters);
-
- // Update map with filtered houses
- if (this.#map) {
- const filteredIds = this.#filtered.map((h) => h.id);
- this.#map.updateHouseVisibility(filteredIds);
- }
-
- this.#updateStats();
- }
-
- /**
- * Apply filters statically
- * @param {House[]} houses
- * @param {Filters} filters
- * @returns {House[]}
- */
- static applyFilters(houses, filters) {
- return houses.filter((h) => h.matchesFilters(filters));
- }
-
/**
* Recalculate scores statically
* @param {House[]} houses
* @param {Weights} weights
*/
- static recalculateScores(houses, weights) {
+ static #recalculateScores(houses, weights) {
for (const h of houses) {
h.scores.current = Math.round(ScoringEngine.calculate(h, weights));
}
}
- /**
- * Update stats display
- */
#updateStats() {
const count = this.#filtered.length;
const avg = count