diff options
| author | Petri Hienonen <petri.hienonen@gmail.com> | 2025-11-09 22:59:02 +0200 |
|---|---|---|
| committer | Petri Hienonen <petri.hienonen@gmail.com> | 2025-11-11 15:35:03 +0200 |
| commit | 909773f9d253c61183cc1f9f6193656957946be5 (patch) | |
| tree | 136075e1946accedda0530dd25940b8931408c5a /app/components.js | |
| parent | be7ec90b500ac68e053f2b58feb085247ef95817 (diff) | |
| download | housing-909773f9d253c61183cc1f9f6193656957946be5.tar.zst | |
Add statistical areas
Diffstat (limited to 'app/components.js')
| -rw-r--r-- | app/components.js | 864 |
1 files changed, 864 insertions, 0 deletions
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(); + } +} |
