// main.js import { Dom, DomOptions, Modal, Widgets } from "dom"; import { ColorParameter, MapEl } from "map"; import { DataProvider, District, Filters, House, ScoringEngine, TrainStation, TrainTracks, Weights, } from "models"; export class App { /** @type {House[]} */ #houses = []; /** @type {TrainTracks[]} */ #trainTracks = []; /** @type {TrainStation[]} */ #trainStations = []; /** @type {House[]} */ #filtered = []; /** @type {Filters} */ #filters = new Filters(); /** @type {Weights} */ #weights = new Weights(); /** @type {District[]} */ #districts = []; /** @type {MapEl} */ #map; /** @type {HTMLElement} */ #stats; /** @type {HTMLElement} */ #controls; /** @type {Modal|null} */ #modal = null; /** @type {boolean} */ #persistent = false; /** @type {string} */ #colorParameter = ColorParameter.price; constructor() { // Set up main layout container Object.assign(document.body.style, { display: "flex", flexDirection: "column", fontFamily: "Roboto Mono", height: "100vh", margin: "0", }); this.#controls = App.buildControls( this.#filters, this.#weights, () => { 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.#map?.setColorParameter(this.#colorParameter); this.#updateStats(); }, (param) => { this.#colorParameter = param; this.#map?.setColorParameter(this.#colorParameter); }, ); this.#map = new MapEl({ onHouseClick: (houseId, persistent) => this.#showHouseModal(houseId, persistent), onHouseHover: (houseId, hide) => { if (hide) { this.#modal?.hide(); } else { this.#showHouseModal(houseId, false); } }, }); 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); } /** * Build controls container * @param {Filters} filters * @param {Weights} weights * @param {() => void} onFilterChange * @param {(key: string, value: number) => void} onWeightChange * @param {(param: string) => void} onColorChange * @returns {HTMLElement} */ 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", display: "flex", flexDirection: "column", flexShrink: "0", gap: "1rem", overflowY: "auto", padding: "1rem", width: "300px", }, }), ); 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.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", }, }), ), ); return controls; } /** * Show modal with house details * @param {string} houseId * @param {boolean} persistent */ #showHouseModal(houseId, persistent) { const house = this.#houses.find((h) => h.id === houseId); if (!house) return; this.#persistent = persistent; if (this.#map) { this.#map.setModalPersistence(persistent); } // 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%", }, () => { this.#modal = null; this.#persistent = false; if (this.#map) { this.#map.setModalPersistence(false); this.#map.clearModalTimer(); } }, () => { if (this.#map) { this.#map.clearModalTimer(); } }, ); document.body.appendChild(this.#modal.render()); this.#modal.show(); } /** * Load data and initialize application * @param {HTMLElement} loading */ async #initialize(loading) { try { const [districts, houses, trainStations, trainTracks, coastLine, mainRoads] = await Promise.all([ DataProvider.getDistricts(), DataProvider.getHouses(), DataProvider.getTrainStations(), DataProvider.getTrainTracks(), DataProvider.getCoastline(), DataProvider.getMainRoads(), ]); this.#districts = districts; this.#houses = houses; this.#trainStations = trainStations; this.#trainTracks = trainTracks; this.#filtered = houses.slice(); this.#map.initialize( districts, coastLine, mainRoads, trainTracks, trainStations, houses, this.#colorParameter, ); // Populate district multi-select const districtOptions = App.#renderDistrictOptions(this.#districts, this.#houses); const districtSelect = this.#controls.querySelector("#district-select"); if (districtSelect) { districtSelect.append(...districtOptions); } this.#updateStats(); } finally { loading.remove(); } } /** * Render district options for multi-select * @param {District[]} _districts * @param {House[]} houses * @returns {HTMLOptionElement[]} */ 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)); } /** * Recalculate scores statically * @param {House[]} houses * @param {Weights} weights */ static #recalculateScores(houses, weights) { for (const h of houses) { h.scores.current = Math.round(ScoringEngine.calculate(h, weights)); } } #updateStats() { const count = this.#filtered.length; const avg = count ? Math.round(this.#filtered.reduce((s, h) => s + h.scores.current, 0) / count) : 0; this.#stats.innerHTML = ` ${count} houses shown • Average score: ${avg} • Use weights sliders to adjust scoring `; } } if (import.meta.url === new URL("./main.js", document.baseURI).href) { new App(); }