aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--app/components.js379
-rw-r--r--app/dom.js192
-rw-r--r--app/main.js497
-rw-r--r--app/models.js42
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
diff --git a/app/dom.js b/app/dom.js
index 3ee393c..9ea7bf9 100644
--- a/app/dom.js
+++ b/app/dom.js
@@ -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 {