From 277ffe2cab8c711427b979fbc057c7d04932398e Mon Sep 17 00:00:00 2001 From: Petri Hienonen Date: Sat, 8 Nov 2025 22:05:01 +0200 Subject: Update DOM --- app/main.js | 560 ++++++++++++++++++++++++++---------------------------------- 1 file changed, 240 insertions(+), 320 deletions(-) (limited to 'app/main.js') 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); @@ -558,63 +512,29 @@ 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 -- cgit v1.2.3-70-g09d2