diff options
| author | Petri Hienonen <petri.hienonen@gmail.com> | 2025-10-29 15:18:30 +0200 |
|---|---|---|
| committer | Petri Hienonen <petri.hienonen@gmail.com> | 2025-11-03 10:54:48 +0200 |
| commit | b03ee7032b2ea2d4d22ab7ec1346b7c9331cfc17 (patch) | |
| tree | efc0ce6823ab8611d9c6a0bf27ecdbd124638b73 /app/main.js | |
| download | housing-b03ee7032b2ea2d4d22ab7ec1346b7c9331cfc17.tar.zst | |
Initial commit
Diffstat (limited to 'app/main.js')
| -rw-r--r-- | app/main.js | 745 |
1 files changed, 745 insertions, 0 deletions
diff --git a/app/main.js b/app/main.js new file mode 100644 index 0000000..01ceef1 --- /dev/null +++ b/app/main.js @@ -0,0 +1,745 @@ +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 = ` + <strong>${count}</strong> houses shown + • Average score: <strong>${avg}</strong> + • Use weights sliders to adjust scoring + `; + } +} + +if (import.meta.url === new URL("./main.js", document.baseURI).href) { + new App(); +} |
