diff options
| author | Petri Hienonen <petri.hienonen@gmail.com> | 2025-11-04 17:07:24 +0200 |
|---|---|---|
| committer | Petri Hienonen <petri.hienonen@gmail.com> | 2025-11-09 22:48:55 +0200 |
| commit | be7ec90b500ac68e053f2b58feb085247ef95817 (patch) | |
| tree | aef7732ce0bbe505c6bc8486e1d0da2c06990e6a /app/main.js | |
| parent | a4ed99a370930b1a0c0f065906ed99c15a015fd4 (diff) | |
| download | housing-be7ec90b500ac68e053f2b58feb085247ef95817.tar.zst | |
Refactor application to use couchbase
Diffstat (limited to 'app/main.js')
| -rw-r--r-- | app/main.js | 864 |
1 files changed, 347 insertions, 517 deletions
diff --git a/app/main.js b/app/main.js index 617b9bb..1273a11 100644 --- a/app/main.js +++ b/app/main.js @@ -1,4 +1,5 @@ -import { Dom } from "dom"; +// main.js +import { Dom, DomOptions, Modal, Widgets } from "dom"; import { ColorParameter, MapEl } from "map"; import { DataProvider, @@ -26,16 +27,14 @@ export class App { #weights = new Weights(); /** @type {District[]} */ #districts = []; - /** @type {MapEl|null} */ - #map = null; + /** @type {MapEl} */ + #map; /** @type {HTMLElement} */ #stats; /** @type {HTMLElement} */ #controls; - /** @type {HTMLDialogElement|null} */ + /** @type {Modal|null} */ #modal = null; - /** @type {number | null} */ - #modalTimer = null; /** @type {boolean} */ #persistent = false; /** @type {string} */ @@ -51,99 +50,105 @@ export class App { margin: "0", }); - const loading = App.createLoading(); - - // Create main content container - const mainContainer = Dom.div({ - styles: { - display: "flex", - flex: "1", - overflow: "hidden", - }, - }); - - // Create map container - const mapContainer = Dom.div({ - styles: { - display: "flex", - flex: "1", - flexDirection: "column", - minWidth: "0", // Prevents flex overflow - }, - }); - - const stats = Dom.div({ - styles: { - background: "#fff", - borderTop: "1px solid #ddd", - flexShrink: "0", - fontSize: "0.95rem", - padding: "0.75rem 1rem", - }, - }); - - const controls = App.buildControls( + 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); }, ); - // Build layout hierarchy - mainContainer.append(controls, mapContainer); - document.body.append(loading, mainContainer); - - this.#stats = stats; - this.#controls = controls; - - // Initialize map this.#map = new MapEl({ onHouseClick: (houseId, persistent) => this.#showHouseModal(houseId, persistent), onHouseHover: (houseId, hide) => { if (hide) { - this.#hideModal(); + this.#modal?.hide(); } else { this.#showHouseModal(houseId, false); } }, }); - mapContainer.append(this.#map.initializeMap(), stats); - this.#loadData(loading); - } - /** - * Create loading indicator - * @returns {HTMLElement} - */ - static createLoading() { - return Dom.div({ - styles: { - background: "white", - borderRadius: "8px", - boxShadow: "0 2px 10px rgba(0,0,0,0.1)", - color: "#555", - fontSize: "1.2rem", - left: "50%", - padding: "2rem", - position: "absolute", - textAlign: "center", - top: "50%", - transform: "translate(-50%, -50%)", - zIndex: "1000", - }, - textContent: "Loading data…", - }); + this.#stats = Dom.div( + new DomOptions({ + id: "stats", + styles: { + background: "#fff", + borderTop: "1px solid #ddd", + flexShrink: "0", + fontSize: "0.95rem", + padding: "0.75rem 1rem", + }, + }), + ); + + const loading = Dom.span( + "Loading data…", + new DomOptions({ + id: "loading", + styles: { + background: "white", + borderRadius: "8px", + boxShadow: "0 2px 10px rgba(0,0,0,0.1)", + color: "#555", + fontSize: "1.2rem", + left: "50%", + padding: "2rem", + position: "absolute", + textAlign: "center", + top: "50%", + transform: "translate(-50%, -50%)", + zIndex: "1000", + }, + }), + ); + + document.body.append( + loading, + Dom.div( + new DomOptions({ + children: [ + this.#controls, + Dom.div( + new DomOptions({ + children: [this.#map.svg, this.#stats], + id: "map-container", + styles: { + display: "flex", + flex: "1", + flexDirection: "column", + minWidth: "0", // Prevents flex overflow + }, + }), + ), + ], + id: "main", + styles: { + display: "flex", + flex: "1", + overflow: "hidden", + }, + }), + ), + ); + this.#initialize(loading); } /** @@ -156,255 +161,240 @@ export class App { * @returns {HTMLElement} */ static buildControls(filters, weights, onFilterChange, onWeightChange, onColorChange) { - const controls = Dom.div({ - styles: { - background: "#fff", - borderRight: "1px solid #ddd", - display: "flex", - flexDirection: "column", - flexShrink: "0", - gap: "1rem", - overflowY: "auto", - padding: "1rem", - width: "300px", - }, - }); - - // Color parameter section - const colorSection = Dom.div({ - styles: { - borderBottom: "1px solid #eee", - paddingBottom: "1rem", - }, - }); - - const colorTitle = Dom.heading(3, { - styles: { - color: "#333", - fontSize: "1.1rem", - margin: "0 0 1rem 0", - }, - textContent: "Map Colors", - }); - - const colorGroup = Dom.div({ - styles: { display: "flex", flexDirection: "column" }, - }); - - const colorLabel = Dom.label({ - for: "color-parameter", - styles: { fontSize: "0.85rem", fontWeight: "bold", marginBottom: "0.25rem" }, - textContent: "Color houses by", - }); - - const colorSelect = Dom.select({ - id: "color-parameter", - onChange: (e) => { - const target = /** @type {HTMLSelectElement} */ (e.target); - onColorChange(target.value); - }, - 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"), + 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", + }, + }), ); - colorGroup.append(colorLabel, colorSelect); - colorSection.append(colorTitle, colorGroup); - controls.appendChild(colorSection); - - // Filter section - const filterSection = Dom.div({ - styles: { - borderBottom: "1px solid #eee", - paddingBottom: "1rem", - }, - }); - - const filterTitle = Dom.heading(3, { - styles: { - color: "#333", - fontSize: "1.1rem", - margin: "0 0 1rem 0", - }, - textContent: "Filters", - }); - - filterSection.appendChild(filterTitle); - - // Price filters in a row - const priceRow = Dom.div({ - 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({ - styles: { display: "flex", flexDirection: "column" }, - }); - - const districtLabel = Dom.label({ - for: "district-select", - styles: { fontSize: "0.85rem", fontWeight: "bold", marginBottom: "0.25rem" }, - textContent: "Districts", - }); - - const districtSelect = Dom.select({ - attributes: { multiple: "true" }, - id: "district-select", - onChange: (e) => { - const target = /** @type {HTMLSelectElement} */ (e.target); - const selectedOptions = Array.from(target.selectedOptions).map((opt) => opt.value); - filters.districts = selectedOptions; - onFilterChange(); - }, - 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({ - styles: { - borderBottom: "1px solid #eee", - paddingBottom: "1rem", - }, - }); - - const weightsTitle = Dom.heading(3, { - styles: { - color: "#333", - fontSize: "1.1rem", - margin: "0 0 1rem 0", - }, - textContent: "Weights", - }); - - 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, + 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(); + }), + + 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(); + }), + + 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.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: [ + 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", + }, + }), ), - Dom.slider( - "w-year", - "Construction year", - "constructionYear", - weights.constructionYear, - onWeightChange, - ), - ]; - - 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({ - styles: { display: "flex", flexDirection: "column", marginBottom: "0.75rem" }, - }); - - const label = Dom.label({ - for: id, - styles: { fontSize: "0.85rem", fontWeight: "bold", marginBottom: "0.25rem" }, - textContent: labelText, - }); - - const input = Dom.input({ - id, - onInput: /** @param {Event} e */ (e) => { - const target = /** @type {HTMLInputElement} */ (e.target); - const raw = target.value.trim(); - onChange(raw === "" ? null : Number(raw)); - }, - placeholder: "any", - styles: { - border: "1px solid #ddd", - borderRadius: "4px", - fontSize: "0.9rem", - padding: "0.5rem", - }, - type: "number", - }); - - group.append(label, input); - return group; - } - - /** * Show modal with house details * @param {string} houseId * @param {boolean} persistent @@ -418,172 +408,44 @@ export class App { this.#map.setModalPersistence(persistent); } - // Remove existing modal - 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%", - }); - - // Add hover grace period listeners - this.#modal.addEventListener("mouseenter", () => { - clearTimeout(this.#modalTimer); - if (this.#map) { - this.#map.clearModalTimer(); - } - }); - - this.#modal.addEventListener("mouseleave", () => { - if (!this.#persistent) { - this.#modalTimer = setTimeout(() => this.#hideModal(), 200); - } - }); - - // Build modal content - const content = this.#buildHouseModalContent(house); - this.#modal.appendChild(content); - document.body.appendChild(this.#modal); - - if (persistent) { - this.#modal.showModal(); - } else { - this.#modal.show(); - } - } - - /** - * Hide the modal - */ - #hideModal() { - this.#modal?.close(); - this.#modal?.remove(); - this.#modal = null; - this.#persistent = false; - clearTimeout(this.#modalTimer); - if (this.#map) { - this.#map.setModalPersistence(false); - this.#map.clearModalTimer(); - } - } - - /** - * Build modal content for a house - * @param {House} house - * @returns {DocumentFragment} - */ - #buildHouseModalContent(house) { - const frag = document.createDocumentFragment(); - - /* Header */ - const header = Dom.div({ - styles: { - alignItems: "center", - display: "flex", - justifyContent: "space-between", - marginBottom: "20px", + // Hide existing modal + this.#modal?.hide(); + + this.#modal = new Modal( + house, + persistent, + { + left: "auto", + maxHeight: "80vh", + maxWidth: "400px", + right: "20px", + top: "50%", + transform: "translateY(-50%)", + width: "90%", }, - }); - const title = Dom.heading(2, { - styles: { color: "#333", fontSize: "20px", margin: "0" }, - textContent: house.address, - }); - const score = Dom.span({ - styles: { - background: "#e8f5e9", - borderRadius: "4px", - color: "#2e7d32", - fontSize: "16px", - fontWeight: "bold", - padding: "4px 8px", + () => { + this.#modal = null; + this.#persistent = false; + if (this.#map) { + this.#map.setModalPersistence(false); + this.#map.clearModalTimer(); + } }, - textContent: `Score: ${house.scores.current}`, - }); - Dom.appendChildren(header, [title, score]); - frag.appendChild(header); - - /* Details grid */ - const grid = Dom.div({ - styles: { - display: "grid", - gap: "15px", - gridTemplateColumns: "repeat(2,1fr)", - marginBottom: "20px", + () => { + if (this.#map) { + this.#map.clearModalTimer(); + } }, - }); - 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({ - children: [ - Dom.div({ - styles: { fontSize: "14px", fontWeight: "bold", marginBottom: "4px" }, - textContent: label, - }), - Dom.div({ styles: { color: "#333", fontSize: "14px" }, textContent: value }), - ], - }); - grid.appendChild(item); - } - frag.appendChild(grid); - - /* Description */ - const descSect = Dom.div({ styles: { marginBottom: "20px" } }); - const descTitle = Dom.div({ - styles: { fontSize: "14px", fontWeight: "bold", marginBottom: "5px" }, - textContent: "Description", - }); - const descText = Dom.p({ - styles: { color: "#333", fontSize: "14px", lineHeight: "1.4", marginTop: "5px" }, - textContent: house.description || "No description available.", - }); - Dom.appendChildren(descSect, [descTitle, descText]); - frag.appendChild(descSect); - - /* Images */ - if (house.images?.length) { - const imgSect = Dom.div({ styles: { marginBottom: "20px" } }); - const imgTitle = Dom.div({ - styles: { fontSize: "14px", fontWeight: "bold", marginBottom: "10px" }, - textContent: "Images", - }); - const imgCont = Dom.div({ - styles: { display: "flex", gap: "10px", overflowX: "auto", paddingBottom: "5px" }, - }); - for (const src of house.images.slice(0, 3)) { - imgCont.appendChild( - Dom.img({ - attributes: { loading: "lazy" }, - src, - styles: { borderRadius: "4px", flexShrink: "0", height: "100px" }, - }), - ); - } - Dom.appendChildren(imgSect, [imgTitle, imgCont]); - frag.appendChild(imgSect); - } + ); - return frag; + document.body.appendChild(this.#modal.render()); + this.#modal.show(); } - /** * Load data and initialize application * @param {HTMLElement} loading */ - async #loadData(loading) { + async #initialize(loading) { try { const [districts, houses, trainStations, trainTracks, coastLine, mainRoads] = await Promise.all([ @@ -594,7 +456,6 @@ export class App { DataProvider.getCoastline(), DataProvider.getMainRoads(), ]); - this.#districts = districts; this.#houses = houses; this.#trainStations = trainStations; @@ -602,15 +463,18 @@ export class App { this.#filtered = houses.slice(); - if (this.#map) { - this.#map.setHouses(houses, this.#colorParameter); - this.#map.setTrainData(trainStations, trainTracks); - this.#map.setDistricts(districts); - this.#map.setMapData(coastLine, mainRoads); - } + this.#map.initialize( + districts, + coastLine, + mainRoads, + trainTracks, + trainStations, + houses, + this.#colorParameter, + ); // 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); @@ -623,62 +487,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 |
