diff options
| author | Petri Hienonen <petri.hienonen@gmail.com> | 2025-11-08 22:05:01 +0200 |
|---|---|---|
| committer | Petri Hienonen <petri.hienonen@gmail.com> | 2025-11-08 22:05:01 +0200 |
| commit | 277ffe2cab8c711427b979fbc057c7d04932398e (patch) | |
| tree | cf14de72a58ac1c0712590cef7805518604cdb89 | |
| parent | a9a0070662c2494b37528d27d7420f3da33e749d (diff) | |
| download | housing-277ffe2cab8c711427b979fbc057c7d04932398e.tar.zst | |
Update DOM
| -rw-r--r-- | app/dom.js | 200 | ||||
| -rw-r--r-- | app/main.js | 560 | ||||
| -rw-r--r-- | app/svg.js | 12 |
3 files changed, 305 insertions, 467 deletions
@@ -34,10 +34,6 @@ export const ToastType = { warning: "warning", }; -/** - * DOM element creation class – every creator applies its own options. - * @class - */ export class Dom { /** * Create a `<div>` @@ -216,29 +212,36 @@ export class Dom { if (o.children) p.append(...o.children); return p; } +} +export class Widgets { /** * Build a modal dialog + * @param {object} style * @param {() => void} onClose * @returns {HTMLDialogElement} */ - static buildModal(onClose) { + static buildModal(style, onClose) { const modal = document.createElement("dialog"); - Object.assign(modal.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", - }); + Object.assign( + modal.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", + }, + style, + ); const closeBtn = Dom.button( "x", @@ -264,7 +267,7 @@ export class Dom { /** * Build modal content for a house - * @param {import("./models.js").House} house + * @param {House} house * @returns {DocumentFragment} */ static buildModalContent(house) { @@ -507,130 +510,45 @@ export class Dom { } /** - * Build modal content for a house - * @param {House} house - * @returns {DocumentFragment} + * Create a number filter input + * @param {string} id + * @param {string} labelText + * @param {(value: number | null) => void} onChange + * @returns {HTMLElement} */ - static buildHouseModalContent(house) { - const frag = document.createDocumentFragment(); - - /* Header */ - const header = Dom.div( - new DomOptions({ - styles: { - alignItems: "center", - display: "flex", - justifyContent: "space-between", - marginBottom: "20px", - }, - }), - ); - const title = Dom.heading( - 2, - house.address, - new DomOptions({ - styles: { color: "#333", fontSize: "20px", margin: "0" }, - }), - ); - const score = Dom.span( - `Score: ${house.scores.current}`, - new DomOptions({ - styles: { - background: "#e8f5e9", - borderRadius: "4px", - color: "#2e7d32", - fontSize: "16px", - fontWeight: "bold", - padding: "4px 8px", - }, - }), - ); - header.append(title, score); - frag.appendChild(header); - - /* Details grid */ - const grid = Dom.div( + static addNumberFilter(id, labelText, onChange) { + return Dom.div( new DomOptions({ - styles: { - display: "grid", - gap: "15px", - gridTemplateColumns: "repeat(2,1fr)", - marginBottom: "20px", - }, - }), - ); - const details = [ - { label: "Price", value: `€${house.price.toLocaleString()}` }, - { 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); - - /* Description */ - const descSect = Dom.div(new DomOptions({ styles: { marginBottom: "20px" } })); - const descTitle = Dom.span( - "Description", - new DomOptions({ - styles: { fontSize: "14px", fontWeight: "bold", marginBottom: "5px" }, - }), - ); - const descText = Dom.p( - house.description || "No description available.", - new DomOptions({ - styles: { color: "#333", fontSize: "14px", lineHeight: "1.4", marginTop: "5px" }, - }), - ); - descSect.append(descTitle, descText); - frag.appendChild(descSect); - - /* Images */ - if (house.images?.length) { - const imgSect = Dom.div(new DomOptions({ styles: { marginBottom: "20px" } })); - const imgTitle = Dom.span( - "Images", - new DomOptions({ - styles: { fontSize: "14px", fontWeight: "bold", marginBottom: "10px" }, - }), - ); - const imgCont = Dom.div( - new DomOptions({ - styles: { display: "flex", gap: "10px", overflowX: "auto", paddingBottom: "5px" }, - }), - ); - for (const src of house.images.slice(0, 3)) { - imgCont.appendChild( - Dom.img( - src, + children: [ + Dom.label( + id, + labelText, new DomOptions({ - attributes: { loading: "lazy" }, - styles: { borderRadius: "4px", flexShrink: "0", height: "100px" }, + styles: { fontSize: "0.85rem", fontWeight: "bold", marginBottom: "0.25rem" }, }), ), - ); - } - imgSect.append(imgTitle, imgCont); - frag.appendChild(imgSect); - } - - return frag; + 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" }, + }), + ); } } 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 @@ -2,10 +2,10 @@ import { LineString, Point, Polygon } from "geom"; export class SvgOptions { attributes; - styles; - id; - classes; children; + classes; + id; + styles; /** * @param {Object} [options] @@ -17,10 +17,10 @@ export class SvgOptions { */ constructor({ attributes = {}, styles = {}, id = "", classes = [], children = [] } = {}) { this.attributes = attributes; - this.styles = styles; - this.id = id; - this.classes = classes; this.children = children; + this.classes = classes; + this.id = id; + this.styles = styles; } } |
