diff options
Diffstat (limited to 'app')
| -rw-r--r-- | app/components.js | 379 | ||||
| -rw-r--r-- | app/dom.js | 192 | ||||
| -rw-r--r-- | app/main.js | 497 | ||||
| -rw-r--r-- | app/models.js | 42 |
4 files changed, 917 insertions, 193 deletions
diff --git a/app/components.js b/app/components.js index 3308b62..42d7242 100644 --- a/app/components.js +++ b/app/components.js @@ -441,6 +441,57 @@ export class Widgets { } /** + * Create a dual range filter input + * @param {string} id + * @param {string} labelText + * @param {number} minValue + * @param {number} maxValue + * @param {number} currentMin + * @param {number} currentMax + * @param {number} step + * @param {(min: number, max: number) => void} onChange + * @returns {HTMLElement} + */ + static rangeFilter(id, labelText, minValue, maxValue, currentMin, currentMax, step, onChange) { + return Dom.div( + new DomOptions({ + children: [ + Dom.label( + id, + labelText, + new DomOptions({ + styles: { + fontSize: "0.85rem", + fontWeight: "bold", + marginBottom: "0.25rem", + }, + }), + ), + Dom.range( + minValue, + maxValue, + currentMin, + currentMax, + step, + (min, max) => onChange(min, max), + new DomOptions({ + id, + styles: { + marginBottom: "1rem", + }, + }), + ), + ], + styles: { + display: "flex", + flexDirection: "column", + marginBottom: "1.75rem", + }, + }), + ); + } + + /** * Create a number filter input * @param {string} id * @param {string} labelText @@ -506,6 +557,8 @@ export class Sidebar { #onColorChange; /** @type {(param: string) => void} */ #onAreaColorChange; + /** @type {HTMLElement|null} */ + #filtersSectionElement; /** * @param {House[]|null} allHouses @@ -542,6 +595,7 @@ export class Sidebar { initialParam, ); + this.#filtersSectionElement = null; this.#rootElement = this.#render(); } @@ -754,12 +808,36 @@ export class Sidebar { }), ); } - /** * @param {Filters} filters * @param {() => void} onChange */ static filtersSection(filters, onChange) { + // Calculate reasonable ranges based on typical values + const priceRange = { + max: 1000000, + min: 0, + step: 10000, + }; + + const yearRange = { + max: new Date().getFullYear(), + min: 1800, + step: 1, + }; + + const areaRange = { + max: 500, + min: 0, + step: 10, + }; + + const lotRange = { + max: 5000, + min: 0, + step: 100, + }; + return Dom.div( new DomOptions({ children: [ @@ -774,33 +852,172 @@ export class Sidebar { }, }), ), + + // Price Range + Dom.div( + new DomOptions({ + children: [ + Dom.label( + "price-range", + "Price Range (€)", + new DomOptions({ + styles: { + fontSize: "0.85rem", + fontWeight: "bold", + marginBottom: "0.25rem", + }, + }), + ), + Dom.range( + priceRange.min, + priceRange.max, + filters.minPrice || priceRange.min, + filters.maxPrice === Number.POSITIVE_INFINITY ? priceRange.max : filters.maxPrice, + priceRange.step, + (min, max) => { + filters.minPrice = min; + filters.maxPrice = max === priceRange.max ? Number.POSITIVE_INFINITY : max; + onChange(); + }, + new DomOptions({ + id: "price-range", + styles: { + marginBottom: "1.5rem", + }, + }), + ), + ], + styles: { + display: "flex", + flexDirection: "column", + }, + }), + ), + + // Construction Year Range Dom.div( new DomOptions({ children: [ - Widgets.numberFilter("min-price", "Min price (€)", filters.minPrice, (v) => { - filters.minPrice = v ?? 0; - onChange(); - }), - Widgets.numberFilter("max-price", "Max price (€)", filters.maxPrice, (v) => { - filters.maxPrice = v ?? Number.POSITIVE_INFINITY; - onChange(); - }), + Dom.label( + "year-range", + "Construction Year", + new DomOptions({ + styles: { + fontSize: "0.85rem", + fontWeight: "bold", + marginBottom: "0.25rem", + }, + }), + ), + Dom.range( + yearRange.min, + yearRange.max, + filters.minYear || yearRange.min, + filters.maxYear === Number.POSITIVE_INFINITY ? yearRange.max : filters.maxYear, + yearRange.step, + (min, max) => { + filters.minYear = min; + filters.maxYear = max === yearRange.max ? Number.POSITIVE_INFINITY : max; + onChange(); + }, + new DomOptions({ + id: "year-range", + styles: { + marginBottom: "1.5rem", + }, + }), + ), ], - id: "price-row", styles: { display: "flex", - gap: "0.5rem", + flexDirection: "column", }, }), ), - Widgets.numberFilter("min-year", "Min year", filters.minYear, (v) => { - filters.minYear = v ?? 0; - onChange(); - }), - Widgets.numberFilter("min-area", "Min area (m²)", filters.minArea, (v) => { - filters.minArea = v ?? 0; - onChange(); - }), + + // Living Area Range + Dom.div( + new DomOptions({ + children: [ + Dom.label( + "area-range", + "Living Area (m²)", + new DomOptions({ + styles: { + fontSize: "0.85rem", + fontWeight: "bold", + marginBottom: "0.25rem", + }, + }), + ), + Dom.range( + areaRange.min, + areaRange.max, + filters.minArea || areaRange.min, + filters.maxArea === Number.POSITIVE_INFINITY ? areaRange.max : filters.maxArea, + areaRange.step, + (min, max) => { + filters.minArea = min; + filters.maxArea = max === areaRange.max ? Number.POSITIVE_INFINITY : max; + onChange(); + }, + new DomOptions({ + id: "area-range", + styles: { + marginBottom: "1.5rem", + }, + }), + ), + ], + styles: { + display: "flex", + flexDirection: "column", + }, + }), + ), + + // Lot Size Range + Dom.div( + new DomOptions({ + children: [ + Dom.label( + "lot-range", + "Lot Size (m²)", + new DomOptions({ + styles: { + fontSize: "0.85rem", + fontWeight: "bold", + marginBottom: "0.25rem", + }, + }), + ), + Dom.range( + lotRange.min, + lotRange.max, + filters.minLot || lotRange.min, + filters.maxLot === Number.POSITIVE_INFINITY ? lotRange.max : filters.maxLot, + lotRange.step, + (min, max) => { + filters.minLot = min; + filters.maxLot = max === lotRange.max ? Number.POSITIVE_INFINITY : max; + onChange(); + }, + new DomOptions({ + id: "lot-range", + styles: { + marginBottom: "1.5rem", + }, + }), + ), + ], + styles: { + display: "flex", + flexDirection: "column", + }, + }), + ), + + // Districts Multi-select Dom.div( new DomOptions({ children: [ @@ -838,8 +1055,43 @@ export class Sidebar { }), ), ], - id: "district-multi-select", - styles: { display: "flex", flexDirection: "column", marginTop: "1rem" }, + styles: { + display: "flex", + flexDirection: "column", + }, + }), + ), + + // Clear Filters Button + Dom.button( + "Clear All Filters", + () => { + // Reset all ranges to their maximum possible values + filters.minPrice = 0; + filters.maxPrice = Number.POSITIVE_INFINITY; + filters.minYear = 1800; + filters.maxYear = Number.POSITIVE_INFINITY; + filters.minArea = 0; + filters.maxArea = Number.POSITIVE_INFINITY; + filters.minLot = 0; + filters.maxLot = Number.POSITIVE_INFINITY; + filters.districts = []; + + // Update the UI by triggering onChange + onChange(); + }, + new DomOptions({ + styles: { + background: "#f44336", + border: "none", + borderRadius: "4px", + color: "white", + cursor: "pointer", + fontSize: "0.85rem", + marginTop: "1rem", + padding: "0.5rem 1rem", + width: "100%", + }, }), ), ], @@ -852,22 +1104,39 @@ export class Sidebar { } /** - * Update histogram data + * Update sidebar with new data * @param {House[]} houses - * @param {string} parameter + * @param {string} histogramParameter */ - updateHistogram(houses, parameter) { - const values = houses.map((house) => house.get(parameter)); - if (this.#histogram) { - this.#histogram.update(values, parameter); - } + update(houses, histogramParameter) { + this.#allHouses = houses; + + // Update histogram + const values = houses.map((house) => house.get(histogramParameter)); + this.#histogram.update(values, histogramParameter); + + // Update filters section + this.#updateFiltersSection(); + + // Update districts + this.updateDistricts(houses); } /** - * Handle histogram bar click - * @param {number} min - * @param {number} max + * Update filters section with current data */ + #updateFiltersSection() { + if (!this.#allHouses || this.#allHouses.length === 0) return; + + const newFiltersSection = Sidebar.filtersSection(this.#filters, this.#onFilterChange); + + if (this.#filtersSectionElement) { + this.#filtersSectionElement.replaceWith(newFiltersSection); + } + + this.#filtersSectionElement = newFiltersSection; + } + /** * Handle histogram bar click * @param {number} min @@ -880,69 +1149,27 @@ export class Sidebar { case HouseParameter.price: { this.#filters.minPrice = min; this.#filters.maxPrice = max; - // Update the input fields to reflect the new filter values - const minPriceInput = document.getElementById("min-price"); - const maxPriceInput = document.getElementById("max-price"); - if (minPriceInput) minPriceInput.value = Math.round(min).toString(); - if (maxPriceInput) maxPriceInput.value = Math.round(max).toString(); break; } case HouseParameter.area: { this.#filters.minArea = min; - // Update the input field to reflect the new filter value - const minAreaInput = document.getElementById("min-area"); - if (minAreaInput) minAreaInput.value = Math.round(min).toString(); + this.#filters.maxArea = max; break; } case HouseParameter.year: { this.#filters.minYear = min; - // Update the input field to reflect the new filter value - const minYearInput = document.getElementById("min-year"); - if (minYearInput) minYearInput.value = Math.round(min).toString(); + this.#filters.maxYear = max; break; } case HouseParameter.score: { - // Note: You might want to add score filters to your Filters class + // Handle score filtering if needed console.log(`Score range: ${min} - ${max}`); - // If you add score filters to Filters class, you would do: - // this.#filters.minScore = min; - // this.#filters.maxScore = max; break; } } - // Update the input fields to show the new filter values - this.#updateFilterInputs(); - // Trigger the filter change to update the application this.#onFilterChange(); } - /** - * Update filter input values to match current filters - */ - #updateFilterInputs() { - const minPriceInput = document.getElementById("min-price"); - const maxPriceInput = document.getElementById("max-price"); - const minAreaInput = document.getElementById("min-area"); - const minYearInput = document.getElementById("min-year"); - - if (minPriceInput) - minPriceInput.value = this.#filters.minPrice - ? Math.round(this.#filters.minPrice).toString() - : ""; - if (maxPriceInput) - maxPriceInput.value = - this.#filters.maxPrice !== Number.POSITIVE_INFINITY - ? Math.round(this.#filters.maxPrice).toString() - : ""; - if (minAreaInput) - minAreaInput.value = this.#filters.minArea - ? Math.round(this.#filters.minArea).toString() - : ""; - if (minYearInput) - minYearInput.value = this.#filters.minYear - ? Math.round(this.#filters.minYear).toString() - : ""; - } /** * @param {Histogram} histogram @@ -125,7 +125,7 @@ export class Dom { * @param {DomOptions} o * @param {string|undefined} text */ - static a(url, o, text) { + static a(url, o, text = undefined) { const link = document.createElement("a"); if (text) link.text = text; link.href = url; @@ -238,4 +238,194 @@ export class Dom { if (o.children) p.append(...o.children); return p; } + + /** + * Create a dual range input (min and max) + * @param {number} min - Minimum possible value + * @param {number} max - Maximum possible value + * @param {number} currentMin - Current minimum value + * @param {number} currentMax - Current maximum value + * @param {number} step - Step size + * @param {(min: number, max: number) => void} onChange - Callback when range changes + * @param {DomOptions} options - DOM options + * @returns {HTMLDivElement} + */ + static range(min, max, currentMin, currentMax, step, onChange, options = new DomOptions()) { + const container = document.createElement("div"); + Object.assign(container.style, options.styles); + if (options.id) container.id = options.id; + for (const cls of options.classes) container.classList.add(cls); + for (const [k, v] of Object.entries(options.attributes)) container.setAttribute(k, v); + + // Ensure current values are within bounds + const safeCurrentMin = Math.max(min, Math.min(currentMin, max)); + const safeCurrentMax = Math.max(min, Math.min(currentMax, max)); + + // Create track container + const trackContainer = Dom.div( + new DomOptions({ + styles: { + alignItems: "center", + display: "flex", + height: "20px", + margin: "10px 0", + position: "relative", + }, + }), + ); + + // Create track + const track = Dom.div( + new DomOptions({ + styles: { + background: "#e0e0e0", + borderRadius: "4px", + height: "6px", + position: "relative", + width: "100%", + }, + }), + ); + + // Create active range + const activeRange = Dom.div( + new DomOptions({ + styles: { + background: "#4caf50", + borderRadius: "4px", + height: "100%", + left: "0%", + position: "absolute", + width: "100%", + }, + }), + ); + + // Create min slider + const minSlider = document.createElement("input"); + minSlider.type = "range"; + minSlider.min = min.toString(); + minSlider.max = max.toString(); + minSlider.step = step.toString(); + minSlider.value = safeCurrentMin.toString(); + Object.assign(minSlider.style, { + appearance: "none", + background: "transparent", + height: "100%", + pointerEvents: "none", + position: "absolute", + width: "100%", + zIndex: "2", + }); + minSlider.style.pointerEvents = "auto"; + + // Create max slider + const maxSlider = document.createElement("input"); + maxSlider.type = "range"; + maxSlider.min = min.toString(); + maxSlider.max = max.toString(); + maxSlider.step = step.toString(); + maxSlider.value = safeCurrentMax.toString(); + Object.assign(maxSlider.style, { + appearance: "none", + background: "transparent", + height: "100%", + pointerEvents: "none", + position: "absolute", + width: "100%", + zIndex: "2", + }); + maxSlider.style.pointerEvents = "auto"; + + // Value displays + const minValueDisplay = Dom.span( + safeCurrentMin.toString(), + new DomOptions({ + styles: { + color: "#0066cc", + fontSize: "0.85rem", + fontWeight: "bold", + }, + }), + ); + + const maxValueDisplay = Dom.span( + safeCurrentMax.toString(), + new DomOptions({ + styles: { + color: "#0066cc", + fontSize: "0.85rem", + fontWeight: "bold", + }, + }), + ); + + const valueDisplay = Dom.div( + new DomOptions({ + children: [minValueDisplay, maxValueDisplay], + styles: { + display: "flex", + justifyContent: "space-between", + marginBottom: "5px", + }, + }), + ); + + // Update active range position + const updateActiveRange = () => { + const minVal = parseInt(minSlider.value); + const maxVal = parseInt(maxSlider.value); + + const minPercent = ((minVal - min) / (max - min)) * 100; + const maxPercent = ((maxVal - min) / (max - min)) * 100; + + activeRange.style.left = `${minPercent}%`; + activeRange.style.width = `${maxPercent - minPercent}%`; + + minValueDisplay.textContent = minVal.toString(); + maxValueDisplay.textContent = maxVal.toString(); + }; + + // Event listeners + minSlider.addEventListener("input", () => { + const minVal = parseInt(minSlider.value); + const maxVal = parseInt(maxSlider.value); + + if (minVal > maxVal) { + minSlider.value = maxVal.toString(); + } + + updateActiveRange(); + onChange(parseInt(minSlider.value), parseInt(maxSlider.value)); + }); + + maxSlider.addEventListener("input", () => { + const minVal = parseInt(minSlider.value); + const maxVal = parseInt(maxSlider.value); + + if (maxVal < minVal) { + maxSlider.value = minVal.toString(); + } + + updateActiveRange(); + onChange(parseInt(minSlider.value), parseInt(maxSlider.value)); + }); + + // Initial update + updateActiveRange(); + + // Assemble track + track.appendChild(activeRange); + track.appendChild(minSlider); + track.appendChild(maxSlider); + trackContainer.appendChild(track); + + // Assemble container + container.appendChild(valueDisplay); + container.appendChild(trackContainer); + + if (options.children) container.append(...options.children); + + return container; + } } diff --git a/app/main.js b/app/main.js index 7adb227..d7fb1b2 100644 --- a/app/main.js +++ b/app/main.js @@ -13,13 +13,271 @@ import { Weights, } from "models"; -export class App { +export class Init { + /** @type {HTMLElement} */ + #loadingElement; /** @type {Collection|null} */ - collection = null; + #collection = null; + + constructor() { + this.#loadingElement = Dom.div( + new DomOptions({ + children: [ + Dom.div( + new DomOptions({ + children: [ + Dom.span( + "🏠", + new DomOptions({ + styles: { + fontSize: "3rem", + marginBottom: "1rem", + }, + }), + ), + Dom.span( + "Loading Housing Application...", + new DomOptions({ + styles: { + color: "#333", + fontSize: "1.2rem", + fontWeight: "500", + }, + }), + ), + Dom.span( + "Please wait while we load and process the data", + new DomOptions({ + styles: { + color: "#666", + fontSize: "0.9rem", + marginTop: "0.5rem", + }, + }), + ), + ], + styles: { + alignItems: "center", + display: "flex", + flexDirection: "column", + textAlign: "center", + }, + }), + ), + ], + styles: { + alignItems: "center", + background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)", + color: "white", + display: "flex", + fontFamily: "Roboto Mono, monospace", + height: "100%", + justifyContent: "center", + left: "0", + position: "fixed", + top: "0", + width: "100%", + zIndex: "9999", + }, + }), + ); + + this.#showLoadingScreen(); + this.#initialize(); + } + + /** + * Show loading screen + */ + #showLoadingScreen() { + document.body.appendChild(this.#loadingElement); + + // Set basic body styles + Object.assign(document.body.style, { + fontFamily: "Roboto Mono, monospace", + margin: "0", + padding: "0", + }); + } + + /** + * Initialize application data + */ + async #initialize() { + try { + // Load collection data + this.#collection = await Collection.get(); + + // Pre-calculate scores for all houses + this.#precalculateScores(); + + // Initialize filters with actual data ranges + const filters = this.#initializeFilters(); + + // Apply initial filtering + const filteredHouses = this.#applyInitialFilters(filters); + + // Create app with fully initialized data + this.#createApp(filters, filteredHouses); + } catch (error) { + console.error("Initialization failed:", error); + this.#showError("Failed to load application data. Please refresh the page."); + } + } + + /** + * Pre-calculate scores for all houses + */ + #precalculateScores() { + if (!this.#collection) return; + + const weights = new Weights(); // Default weights for initial calculation + + this.#collection.houses.forEach((house) => { + house.scores.current = Math.round(ScoringEngine.calculate(house, weights)); + house.value = house.scores.current; + }); + } + + /** + * Initialize filters with data ranges + * @returns {Filters} + */ + #initializeFilters() { + const filters = new Filters(); + + if (this.#collection) { + filters.updateRanges(this.#collection.houses); + } + + return filters; + } + + /** + * Apply initial filters + * @param {Filters} filters + * @returns {House[]} + */ + #applyInitialFilters(filters) { + if (!this.#collection) return []; + + return this.#collection.houses.filter((house) => house.matchesFilters(filters)); + } + + /** + * Create the main application + * @param {Filters} filters + * @param {House[]} filteredHouses + */ + #createApp(filters, filteredHouses) { + if (!this.#collection) return; + + // Remove loading screen + this.#loadingElement.remove(); + + // Create app with fully initialized data + new App(this.#collection, filters, filteredHouses); + } + + /** + * Show error message + * @param {string} message + */ + #showError(message) { + this.#loadingElement.remove(); + + const errorElement = Dom.div( + new DomOptions({ + children: [ + Dom.div( + new DomOptions({ + children: [ + Dom.span( + "❌", + new DomOptions({ + styles: { + fontSize: "3rem", + marginBottom: "1rem", + }, + }), + ), + Dom.span( + "Application Error", + new DomOptions({ + styles: { + color: "#c53030", + fontSize: "1.5rem", + fontWeight: "bold", + marginBottom: "1rem", + }, + }), + ), + Dom.span( + message, + new DomOptions({ + styles: { + color: "#744210", + fontSize: "1rem", + lineHeight: "1.5", + marginBottom: "2rem", + textAlign: "center", + }, + }), + ), + Dom.button( + "Refresh Page", + () => location.reload(), + new DomOptions({ + styles: { + background: "#c53030", + border: "none", + borderRadius: "6px", + color: "white", + cursor: "pointer", + fontSize: "1rem", + padding: "0.75rem 1.5rem", + transition: "background-color 0.2s", + }, + }), + ), + ], + styles: { + alignItems: "center", + display: "flex", + flexDirection: "column", + maxWidth: "400px", + textAlign: "center", + }, + }), + ), + ], + styles: { + alignItems: "center", + background: "#fed7d7", + display: "flex", + fontFamily: "Roboto Mono, monospace", + height: "100%", + justifyContent: "center", + left: "0", + position: "fixed", + top: "0", + width: "100%", + zIndex: "9999", + }, + }), + ); + + document.body.appendChild(errorElement); + } +} + +export class App { + /** @type {Collection} */ + #collection; /** @type {House[]} */ - #filtered = []; + #filtered; /** @type {Filters} */ - #filters = new Filters(); + #filters; /** @type {Weights} */ #weights = new Weights(); /** @type {MapEl} */ @@ -35,75 +293,65 @@ export class App { /** @type {AreaParam} */ #areaParameter = AreaParam.unemploymentRate; - constructor() { - // Set up main layout container - Object.assign(document.body.style, { - display: "flex", - flexDirection: "column", - fontFamily: "Roboto Mono", - height: "100vh", - margin: "0", - }); + /** + * @param {Collection} collection + * @param {Filters} filters + * @param {House[]} filteredHouses + */ + constructor(collection, filters, filteredHouses) { + this.#collection = collection; + this.#filters = filters; + this.#filtered = filteredHouses; + + this.#setupLayout(); - // Create sidebar instance this.#sidebar = new Sidebar( - null, + this.#collection.houses, this.#filters, this.#weights, - () => { - this.#applyFiltersAndScoring(); - }, - (key, value) => { - if (key in this.#weights) { - this.#weights[/** @type {keyof Weights} */ (key)] = value; - } - this.#applyFiltersAndScoring(); - }, - (param) => { - this.#houseParameter = param; - this.#map.updateHousesColor(this.#houseParameter); - }, - (param) => { - this.#areaParameter = param; - this.#map.updateArea(this.#areaParameter); - }, + () => this.#applyFiltersAndScoring(), + (key, value) => this.#handleWeightChange(key, value), + (param) => this.#handleHouseColorChange(param), + (param) => this.#handleAreaColorChange(param), ); + // Create map this.#map = new MapEl({ onHouseClick: (houseId, persistent) => this.#showHouseModal(houseId, persistent), - onHouseHover: (houseId, hide) => { - if (hide) { - this.#modal?.hide(); - } else { - this.#showHouseModal(houseId, false); - } - }, + onHouseHover: (houseId, hide) => this.#handleHouseHover(houseId, hide), }); - 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", - }, - }), - ); + // Initialize map with data + this.#map.initialize(this.#collection, this.#houseParameter, this.#areaParameter); + + // Create stats display + this.#stats = this.#createStats(); + + // Assemble the main UI + this.#renderUI(); - this.#stats = App.#getStats(this.#filtered); - document.body.append( - loading, + // Update sidebar with current state + this.#sidebar.update(this.#filtered, this.#houseParameter); + } + + /** + * Set up the main application layout + */ + #setupLayout() { + Object.assign(document.body.style, { + display: "flex", + flexDirection: "column", + fontFamily: "Roboto Mono", + height: "100vh", + margin: "0", + }); + } + + /** + * Render the main UI + */ + #renderUI() { + document.body.appendChild( Dom.div( new DomOptions({ children: [ @@ -130,7 +378,50 @@ export class App { }), ), ); - this.#initialize(loading); + } + + /** + * Handle weight changes + * @param {string} key + * @param {number} value + */ + #handleWeightChange(key, value) { + if (key in this.#weights) { + this.#weights[/** @type {keyof Weights} */ (key)] = value; + } + this.#applyFiltersAndScoring(); + } + + /** + * Handle house color parameter change + * @param {string} param + */ + #handleHouseColorChange(param) { + this.#houseParameter = param; + this.#map.updateHousesColor(this.#houseParameter); + this.#sidebar.update(this.#filtered, this.#houseParameter); + } + + /** + * Handle area color parameter change + * @param {string} param + */ + #handleAreaColorChange(param) { + this.#areaParameter = param; + this.#map.updateArea(this.#areaParameter); + } + + /** + * Handle house hover events + * @param {string} houseId + * @param {boolean} hide + */ + #handleHouseHover(houseId, hide) { + if (hide) { + this.#modal?.hide(); + } else { + this.#showHouseModal(houseId, false); + } } /** @@ -139,7 +430,7 @@ export class App { * @param {boolean} persistent */ #showHouseModal(houseId, persistent) { - const house = this.collection?.houses.find((h) => h.id === houseId); + const house = this.#collection.houses.find((h) => h.id === houseId); if (!house) return; this.#map.setModalPersistence(persistent); @@ -174,27 +465,27 @@ export class App { } /** - * Load data and initialize application - * @param {HTMLElement} loading + * Apply filters and recalculate scores */ - async #initialize(loading) { - try { - this.collection = await Collection.get(); + #applyFiltersAndScoring() { + // Recalculate all scores with current weights + App.#recalculateScores(this.#collection.houses, this.#weights); - App.#recalculateScores(this.collection.houses, this.#weights); - this.#filtered = this.collection.houses.filter((h) => h.matchesFilters(this.#filters)); - this.#map.initialize(this.collection, this.#houseParameter, this.#areaParameter); + // Apply filters + this.#filtered = this.#collection.houses.filter((h) => h.matchesFilters(this.#filters)); - this.#sidebar.updateDistricts(this.collection.houses); - this.#sidebar.setAreaColorParameter(this.#areaParameter); - this.#sidebar.updateHistogram(this.#filtered, this.#houseParameter); + // Update map with filtered houses and new scores + const filteredIds = this.#filtered.map((h) => h.id); + this.#map.updateHouseVisibility(filteredIds); + this.#map.updateHousesColor(this.#houseParameter); - const stats = App.#getStats(this.#filtered); - this.#stats.replaceWith(stats); - this.#stats = stats; - } finally { - loading.remove(); - } + // Update statistics + const stats = this.#createStats(); + this.#stats.replaceWith(stats); + this.#stats = stats; + + // Update sidebar + this.#sidebar.update(this.#filtered, this.#houseParameter); } /** @@ -210,46 +501,20 @@ export class App { } /** - * Apply filters and recalculate scores + * Create statistics display + * @returns {HTMLElement} */ - #applyFiltersAndScoring() { - if (!this.collection) return; - - // First recalculate all scores with current weights - App.#recalculateScores(this.collection.houses, this.#weights); - - // Then apply filters - this.#filtered = this.collection.houses.filter((h) => h.matchesFilters(this.#filters)); - - // Update map with filtered houses and new scores - const filteredIds = this.#filtered.map((h) => h.id); - this.#map.updateHouseVisibility(filteredIds); - this.#map.updateHousesColor(this.#houseParameter); - - // Update statistics - const stats = App.#getStats(this.#filtered); - this.#stats.replaceWith(stats); - this.#stats = stats; + #createStats() { + const averageScore = this.#filtered.length + ? Math.round(this.#filtered.reduce((s, h) => s + h.scores.current, 0) / this.#filtered.length) + : 0; - this.#sidebar.updateHistogram(this.#filtered, this.#houseParameter); - } - - /** - * Update statistics display using DOM methods - * @param {House[]} filtered - */ - static #getStats(filtered) { return Dom.div( new DomOptions({ children: [ - Dom.strong(filtered.length.toString()), + Dom.strong(this.#filtered.length.toString()), Dom.span(" houses shown • Average score: "), - Dom.strong( - (filtered.length - ? Math.round(filtered.reduce((s, h) => s + h.scores.current, 0) / filtered.length) - : 0 - ).toString(), - ), + Dom.strong(averageScore.toString()), Dom.span(" • Use weights sliders to adjust scoring"), ], id: "stats", @@ -266,5 +531,5 @@ export class App { } if (import.meta.url === new URL("./main.js", document.baseURI).href) { - new App(); + new Init(); } diff --git a/app/models.js b/app/models.js index b4584f7..e3f6381 100644 --- a/app/models.js +++ b/app/models.js @@ -712,10 +712,52 @@ export class Filters { this.minPrice = 0; this.maxPrice = Number.POSITIVE_INFINITY; this.minYear = 1800; + this.maxYear = Number.POSITIVE_INFINITY; this.minArea = 0; + this.maxArea = Number.POSITIVE_INFINITY; + this.minLot = 0; + this.maxLot = Number.POSITIVE_INFINITY; /** @type {string[]} */ this.districts = []; } + + /** + * Update filter ranges based on house data + * @param {House[]} houses + */ + updateRanges(houses) { + if (!houses || houses.length === 0) return; + + const prices = houses.map((h) => h.price).filter((p) => p > 0); + const years = houses.map((h) => h.constructionYear).filter((y) => y && y > 0); + const areas = houses.map((h) => h.livingArea).filter((a) => a && a > 0); + const lots = houses.map((h) => h.totalArea).filter((l) => l && l > 0); + + // Set reasonable defaults if no data + const defaultPrice = 500000; + const defaultYear = new Date().getFullYear(); + const defaultArea = 200; + const defaultLot = 1000; + + // Update min/max values, ensuring they're reasonable + this.minPrice = Math.min(...prices) || 0; + this.maxPrice = Math.max(...prices) || defaultPrice; + + this.minYear = Math.min(...years) || 1800; + this.maxYear = Math.max(...years) || defaultYear; + + this.minArea = Math.min(...areas) || 0; + this.maxArea = Math.max(...areas) || defaultArea; + + this.minLot = Math.min(...lots) || 0; + this.maxLot = Math.max(...lots) || defaultLot; + + // Ensure min doesn't exceed max + this.minPrice = Math.min(this.minPrice, this.maxPrice); + this.minYear = Math.min(this.minYear, this.maxYear); + this.minArea = Math.min(this.minArea, this.maxArea); + this.minLot = Math.min(this.minLot, this.maxLot); + } } export class Collection { |
