import { Dom } 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|null} */ #map = null; /** @type {HTMLElement} */ #stats; /** @type {HTMLElement} */ #controls; /** @type {HTMLDialogElement|null} */ #modal = null; /** @type {number | null} */ #modalTimer = 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", }); 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.#filters, this.#weights, () => this.#applyFilters(), (key, value) => { if (key in this.#weights) { this.#weights[/** @type {keyof Weights} */ (key)] = value; } App.recalculateScores(this.#houses, this.#weights); this.#updateMapHouseColors(); this.#updateStats(); }, (param) => { this.#colorParameter = param; this.#updateMapHouseColors(); }, ); // 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(); } 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…", }); } /** * 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({ 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"), ); 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 = [ App.addSlider("w-price", "Price weight", "price", weights.price, onWeightChange), App.addSlider( "w-market", "Market distance", "distanceMarket", weights.distanceMarket, onWeightChange, ), App.addSlider( "w-school", "School distance", "distanceSchool", weights.distanceSchool, onWeightChange, ), App.addSlider("w-crime", "Crime rate", "crimeRate", weights.crimeRate, onWeightChange), App.addSlider("w-safety", "Safety index", "safety", weights.safety, onWeightChange), App.addSlider("w-students", "S2 students", "s2Students", weights.s2Students, onWeightChange), App.addSlider( "w-railway", "Railway distance", "distanceRailway", weights.distanceRailway, onWeightChange, ), App.addSlider( "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; } /** * 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 addSlider(id, labelText, weightKey, initialValue, onChange) { const group = Dom.div({ styles: { display: "flex", flexDirection: "column", marginBottom: "1rem" }, }); const label = Dom.label({ for: id }); const output = Dom.span({ id: `${id}-value`, styles: { color: "#0066cc", fontSize: "0.85rem", fontWeight: "bold", textAlign: "center" }, textContent: initialValue.toFixed(1), }); const labelTextSpan = Dom.span({ styles: { fontSize: "0.85rem" }, textContent: labelText, }); label.append(labelTextSpan, " ", output); const slider = Dom.input({ attributes: { max: "1", min: "0", step: "0.1", value: initialValue.toString() }, id, onInput: /** @param {Event} e */ (e) => { const target = /** @type {HTMLInputElement} */ (e.target); const val = Number(target.value); output.textContent = val.toFixed(1); onChange(weightKey, val); }, styles: { margin: "0.5rem 0", width: "100%", }, type: "range", }); group.append(label, slider); return group; } /** * 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); } // 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", }, }); 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", }, 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", }, }); 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; } /** * Load data and initialize application * @param {HTMLElement} loading */ async #loadData(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(); if (this.#map) { this.#map.setDistricts(districts); this.#map.setTrainData(trainStations, trainTracks); this.#map.setHouses(houses, this.#colorParameter); this.#map.setMapData(coastLine, mainRoads); } // 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(); } } /** * 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) { // 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) { 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 ? 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(); }