aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorPetri Hienonen <petri.hienonen@gmail.com>2025-11-14 21:39:29 +0200
committerPetri Hienonen <petri.hienonen@gmail.com>2025-11-15 14:03:04 +0200
commit64acc82b9634d948517ec5bb2ebe5a33cdf22df6 (patch)
tree0c82b618fa398caa2abcebeb573ac85ba29be3ef
parent55085dae685305d24c29b60b1c16fc7dc76831af (diff)
downloadhousing-64acc82b9634d948517ec5bb2ebe5a33cdf22df6.tar.zst
Cleanup
-rw-r--r--app/components.js812
-rw-r--r--app/dom.js29
-rw-r--r--app/main.js307
-rw-r--r--app/map.js220
-rw-r--r--app/models.js48
5 files changed, 619 insertions, 797 deletions
diff --git a/app/components.js b/app/components.js
index 42d7242..e1506a3 100644
--- a/app/components.js
+++ b/app/components.js
@@ -15,16 +15,17 @@ export class Histogram {
#currentParameter = HouseParameter.price;
/**
- * @param {number[]} values
- * @param {number} bins
- * @param {(min: number, max: number) => void} onBarClick
- * @param {string} parameter
+ * @param {Object} options
+ * @param {number[]} options.values
+ * @param {number} options.bins
+ * @param {(min: number, max: number) => void} options.onBarClick
+ * @param {string} options.parameter
*/
- constructor(values, bins, onBarClick, parameter) {
- this.#values = values.filter((v) => !Number.isNaN(v) && Number.isFinite(v));
- this.#bins = bins;
- this.#onBarClick = onBarClick;
- this.#currentParameter = parameter;
+ constructor(options) {
+ this.#values = options.values.filter((v) => !Number.isNaN(v) && Number.isFinite(v));
+ this.#bins = options.bins;
+ this.#onBarClick = options.onBarClick;
+ this.#currentParameter = options.parameter;
this.#rootElement = this.#render();
}
@@ -191,14 +192,13 @@ export class Histogram {
}),
);
- // Add event listeners
bar.addEventListener("mouseenter", () => {
bar.setAttribute("fill", "#2e7d32");
});
bar.addEventListener("mouseleave", () => {
bar.setAttribute("fill", "#4caf50");
});
- bar.addEventListener("click", () => {
+ bar.addEventListener("pointerdown", () => {
this.#onBarClick(bin.min, bin.max);
});
@@ -336,6 +336,131 @@ export class Histogram {
export class Widgets {
/**
+ * Create a range filter with label
+ * @param {string} label - Filter label
+ * @param {number} min - Minimum value
+ * @param {number} max - Maximum value
+ * @param {number} currentMin - Current minimum value
+ * @param {number} currentMax - Current maximum value
+ * @param {(min: number, max: number) => void} onChange - Change callback
+ * @param {number} step - Step size (default: 1)
+ * @param {DomOptions} domOptions - DOM options
+ * @returns {HTMLElement}
+ */
+ static range(
+ label,
+ min,
+ max,
+ currentMin,
+ currentMax,
+ onChange,
+ step = 1,
+ domOptions = new DomOptions(),
+ ) {
+ const id = domOptions.id || `range-${label.toLowerCase().replace(/\s+/g, "-")}`;
+
+ return Dom.div(
+ new DomOptions({
+ attributes: domOptions.attributes,
+ children: [
+ Dom.label(
+ id,
+ label,
+ new DomOptions({
+ styles: {
+ fontSize: "0.85rem",
+ fontWeight: "bold",
+ marginBottom: "0.25rem",
+ },
+ }),
+ ),
+ Dom.range(
+ min,
+ max,
+ currentMin,
+ currentMax,
+ step,
+ onChange,
+ new DomOptions({
+ id,
+ styles: {
+ marginBottom: "1.5rem",
+ ...domOptions.styles,
+ },
+ }),
+ ),
+ ],
+ classes: domOptions.classes,
+ styles: {
+ display: "flex",
+ flexDirection: "column",
+ ...domOptions.styles,
+ },
+ }),
+ );
+ }
+
+ /**
+ * Create a dropdown (select) component with label
+ * @param {string} label - Label text
+ * @param {Array<{value: string, text: string}>} options - Dropdown options
+ * @param {string} defaultValue - Default selected value
+ * @param {(value: string) => void} onChange - Change callback
+ * @param {DomOptions} domOptions - DOM options for the container
+ * @returns {HTMLDivElement}
+ */
+ static dropdown(label, options, defaultValue, onChange, domOptions = new DomOptions()) {
+ const selectId = domOptions.id || `dropdown-${Math.random().toString(36).substr(2, 9)}`;
+
+ return Dom.div(
+ new DomOptions({
+ attributes: domOptions.attributes,
+ children: [
+ Dom.label(
+ selectId,
+ label,
+ new DomOptions({
+ styles: {
+ fontSize: "0.85rem",
+ fontWeight: "bold",
+ marginBottom: "0.25rem",
+ },
+ }),
+ ),
+ Dom.select(
+ defaultValue,
+ (e) => {
+ const target = /** @type {HTMLSelectElement} */ (e.target);
+ onChange(target.value);
+ },
+ new DomOptions({
+ children: options.map((opt) =>
+ Dom.option(opt.value, opt.text, opt.value === defaultValue),
+ ),
+ id: selectId,
+ styles: {
+ border: "1px solid #ddd",
+ borderRadius: "4px",
+ fontSize: "0.9rem",
+ padding: "0.5rem",
+ width: "100%",
+ },
+ }),
+ ),
+ ],
+ classes: domOptions.classes,
+ id: domOptions.id ? `${domOptions.id}-container` : undefined,
+ styles: {
+ display: "flex",
+ flexDirection: "column",
+ marginBottom: "1rem",
+ ...domOptions.styles,
+ },
+ }),
+ );
+ }
+
+ /**
* Show toast notification
* @param {string} message
* @param {ToastType} [type=ToastType.error]
@@ -490,63 +615,23 @@ export class Widgets {
}),
);
}
-
- /**
- * Create a number filter input
- * @param {string} id
- * @param {string} labelText
- * @param {string|number} value
- * @param {(value: number | null) => void} onChange
- * @returns {HTMLElement}
- */
- static numberFilter(id, labelText, value, onChange) {
- return Dom.div(
- new DomOptions({
- children: [
- Dom.label(
- id,
- labelText,
- new DomOptions({
- styles: { fontSize: "0.85rem", fontWeight: "bold", marginBottom: "0.25rem" },
- }),
- ),
- Dom.input(
- "number",
- (e) => {
- const target = /** @type {HTMLInputElement} */ (e.target);
- const raw = target.value.trim();
- onChange(raw === "" ? null : Number(raw));
- },
- value,
- "any",
- new DomOptions({
- id,
- styles: {
- border: "1px solid #ddd",
- borderRadius: "4px",
- fontSize: "0.9rem",
- padding: "0.5rem",
- },
- }),
- ),
- ],
- styles: { display: "flex", flexDirection: "column", marginBottom: "1.75rem" },
- }),
- );
- }
}
export class Sidebar {
/** @type {Histogram} */
#histogram;
- /** @type {House[]|null} */
- #allHouses = [];
+ /** @type {House[]} */
+ #allHouses;
/** @type {HTMLElement} */
#rootElement;
/** @type {boolean} */
#collapsed = false;
/** @type {Filters} */
#filters;
+ /** @type {AreaParam} */
+ #areaParam;
+ /** @type {HouseParameter} */
+ #houseParam;
/** @type {Weights} */
#weights;
/** @type {() => void} */
@@ -561,39 +646,35 @@ export class Sidebar {
#filtersSectionElement;
/**
- * @param {House[]|null} allHouses
- * @param {Filters} filters
- * @param {Weights} weights
- * @param {() => void} onFilterChange
- * @param {(key: string, value: number) => void} onWeightChange
- * @param {(param: string) => void} onColorChange
- * @param {(param: string) => void} onAreaColorChange
+ * @param {Object} options
+ * @param {House[]} options.allHouses
+ * @param {AreaParam} options.areaParam *
+ * @param {AreaParam} options.houseParam
+ * @param {Filters} options.filters
+ * @param {Weights} options.weights
+ * @param {() => void} options.onFilterChange
+ * @param {(key: string, value: number) => void} options.onWeightChange
+ * @param {(param: string) => void} options.onHouseParamChange
+ * @param {(param: string) => void} options.onAreaParamChange
*/
- constructor(
- allHouses,
- filters,
- weights,
- onFilterChange,
- onWeightChange,
- onColorChange,
- onAreaColorChange,
- ) {
- this.#allHouses = allHouses;
- this.#filters = filters;
- this.#weights = weights;
- this.#onFilterChange = onFilterChange;
- this.#onWeightChange = onWeightChange;
- this.#onColorChange = onColorChange;
- this.#onAreaColorChange = onAreaColorChange;
-
- const initialParam = HouseParameter.price;
- const initialValues = this.#allHouses?.map((house) => house.get(initialParam));
- this.#histogram = new Histogram(
- initialValues || [],
- 5,
- (min, max) => this.#handleHistogramClick(min, max),
- initialParam,
- );
+ constructor(options) {
+ this.#areaParam = options.areaParam;
+ this.#houseParam = options.houseParam;
+ this.#allHouses = options.allHouses;
+ this.#filters = options.filters;
+ this.#weights = options.weights;
+ this.#onFilterChange = options.onFilterChange;
+ this.#onWeightChange = options.onWeightChange;
+ this.#onColorChange = options.onHouseParamChange;
+ this.#onAreaColorChange = options.onAreaParamChange;
+
+ const initialValues = this.#allHouses?.map((house) => house.get(this.#houseParam));
+ this.#histogram = new Histogram({
+ bins: 5,
+ onBarClick: (min, max) => this.#handleHistogramClick(min, max),
+ parameter: this.#houseParam,
+ values: initialValues || [],
+ });
this.#filtersSectionElement = null;
this.#rootElement = this.#render();
@@ -697,10 +778,12 @@ export class Sidebar {
}
/**
+ * @param {AreaParam} areaParam
+ * @param {HouseParameter} houseParam
* @param {(param: string) => void} onHouseChange
* @param {(param: string) => void} onAreaChange
*/
- static dataSection(onHouseChange, onAreaChange) {
+ static dataSection(areaParam, houseParam, onHouseChange, onAreaChange) {
return Dom.div(
new DomOptions({
children: [
@@ -715,89 +798,36 @@ export class Sidebar {
},
}),
),
- Dom.div(
+ Widgets.dropdown(
+ "Color houses by",
+ [
+ { text: "Price", value: HouseParameter.price },
+ { text: "Score", value: HouseParameter.score },
+ { text: "Construction Year", value: HouseParameter.year },
+ { text: "Living Area", value: HouseParameter.area },
+ ],
+ houseParam,
+ (value) => onHouseChange(value),
new DomOptions({
- children: [
- Dom.label(
- "color-parameter",
- "Color houses by",
- new DomOptions({
- styles: {
- fontSize: "0.85rem",
- fontWeight: "bold",
- marginBottom: "0.25rem",
- },
- }),
- ),
- Dom.select(
- (e) => {
- const target = /** @type {HTMLSelectElement} */ (e.target);
- onHouseChange(target.value);
- },
- new DomOptions({
- children: [
- Dom.option(HouseParameter.price, "Price"),
- Dom.option(HouseParameter.score, "Score"),
- Dom.option(HouseParameter.year, "Construction Year"),
- Dom.option(HouseParameter.area, "Living Area"),
- ],
- id: "color-parameter",
- styles: {
- border: "1px solid #ddd",
- borderRadius: "4px",
- fontSize: "0.9rem",
- padding: "0.5rem",
- width: "100%",
- },
- }),
- ),
- ],
+ id: "color-parameter",
styles: {
- display: "flex",
- flexDirection: "column",
marginBottom: "1rem",
},
}),
),
- Dom.div(
+ Widgets.dropdown(
+ "Color areas by",
+ [
+ { text: "None", value: AreaParam.none },
+ { text: "Foreign speakers", value: AreaParam.foreignSpeakers },
+ { text: "Unemployment rate", value: AreaParam.unemploymentRate },
+ { text: "Average income", value: AreaParam.averageIncome },
+ { text: "Higher education", value: AreaParam.higherEducation },
+ ],
+ areaParam,
+ (value) => onAreaChange(value),
new DomOptions({
- children: [
- Dom.label(
- "area-color-parameter",
- "Color areas by",
- new DomOptions({
- styles: {
- fontSize: "0.85rem",
- fontWeight: "bold",
- marginBottom: "0.25rem",
- },
- }),
- ),
- Dom.select(
- (e) => {
- const target = /** @type {HTMLSelectElement} */ (e.target);
- onAreaChange(target.value);
- },
- new DomOptions({
- children: [
- Dom.option(AreaParam.none, "None"),
- Dom.option(AreaParam.foreignSpeakers, "Foreign speakers"),
- Dom.option(AreaParam.unemploymentRate, "Unemployment rate"),
- Dom.option(AreaParam.averageIncome, "Average income"),
- Dom.option(AreaParam.higherEducation, "Higher education"),
- ],
- id: "area-color-parameter",
- styles: {
- border: "1px solid #ddd",
- borderRadius: "4px",
- fontSize: "0.9rem",
- padding: "0.5rem",
- width: "100%",
- },
- }),
- ),
- ],
- styles: { display: "flex", flexDirection: "column" },
+ id: "area-color-parameter",
}),
),
],
@@ -810,31 +840,31 @@ export class Sidebar {
}
/**
* @param {Filters} filters
+ * @param {House[]} houses
* @param {() => void} onChange
*/
- static filtersSection(filters, onChange) {
- // Calculate reasonable ranges based on typical values
+ static filtersSection(filters, houses, onChange) {
const priceRange = {
- max: 1000000,
- min: 0,
+ max: filters.maxPrice,
+ min: filters.minPrice,
step: 10000,
};
const yearRange = {
- max: new Date().getFullYear(),
- min: 1800,
+ max: filters.maxYear,
+ min: filters.minYear,
step: 1,
};
const areaRange = {
- max: 500,
- min: 0,
+ max: filters.maxArea,
+ min: filters.minArea,
step: 10,
};
const lotRange = {
- max: 5000,
- min: 0,
+ max: filters.maxArea,
+ min: filters.minArea,
step: 100,
};
@@ -853,167 +883,69 @@ export class Sidebar {
}),
),
- // Price Range
- Dom.div(
+ Widgets.range(
+ "Price Range (€)",
+ priceRange.min,
+ priceRange.max,
+ filters.minPrice,
+ filters.maxPrice,
+ (min, max) => {
+ filters.minPrice = min;
+ filters.maxPrice = max === priceRange.max ? Number.POSITIVE_INFINITY : max;
+ onChange();
+ },
+ priceRange.step,
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",
- },
+ id: "price-range",
}),
),
-
- // Construction Year Range
- Dom.div(
+ Widgets.range(
+ "Construction Year",
+ yearRange.min,
+ yearRange.max,
+ filters.minYear,
+ filters.maxYear,
+ (min, max) => {
+ filters.minYear = min;
+ filters.maxYear = max === yearRange.max ? Number.POSITIVE_INFINITY : max;
+ onChange();
+ },
+ yearRange.step,
new DomOptions({
- children: [
- 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",
- },
- }),
- ),
- ],
- styles: {
- display: "flex",
- flexDirection: "column",
- },
+ id: "year-range",
}),
),
- // Living Area Range
- Dom.div(
+ Widgets.range(
+ "Living Area (m²)",
+ areaRange.min,
+ areaRange.max,
+ filters.minArea,
+ filters.maxArea,
+ (min, max) => {
+ filters.minArea = min;
+ filters.maxArea = max === areaRange.max ? Number.POSITIVE_INFINITY : max;
+ onChange();
+ },
+ areaRange.step,
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",
- },
+ id: "area-range",
}),
),
-
- // Lot Size Range
- Dom.div(
+ Widgets.range(
+ "Lot Size (m²)",
+ lotRange.min,
+ lotRange.max,
+ filters.minLot,
+ filters.maxLot,
+ (min, max) => {
+ filters.minLot = min;
+ filters.maxLot = max === lotRange.max ? Number.POSITIVE_INFINITY : max;
+ onChange();
+ },
+ lotRange.step,
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",
- },
+ id: "lot-range",
}),
),
@@ -1033,6 +965,7 @@ export class Sidebar {
}),
),
Dom.select(
+ undefined,
(e) => {
const target = /** @type {HTMLSelectElement} */ (e.target);
const selectedOptions = Array.from(target.selectedOptions).map(
@@ -1043,7 +976,7 @@ export class Sidebar {
},
new DomOptions({
attributes: { multiple: "true" },
- children: [],
+ children: [...Sidebar.#renderDistrictOptions(houses)],
id: "district-select",
styles: {
border: "1px solid #ddd",
@@ -1067,15 +1000,7 @@ export class Sidebar {
"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 = [];
+ filters.reset();
// Update the UI by triggering onChange
onChange();
@@ -1105,30 +1030,26 @@ export class Sidebar {
/**
* Update sidebar with new data
- * @param {House[]} houses
- * @param {string} histogramParameter
+ * @param {HouseParameter} houseParam
*/
- 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);
+ update(houseParam) {
+ const values = this.#allHouses.map((house) => house.get(houseParam));
+ this.#histogram.update(values, houseParam);
+ this.#updateFiltersSection(this.#allHouses);
}
/**
* Update filters section with current data
+ * @param {House[]} houses
*/
- #updateFiltersSection() {
- if (!this.#allHouses || this.#allHouses.length === 0) return;
+ #updateFiltersSection(houses) {
+ if (!houses || houses.length === 0) return;
- const newFiltersSection = Sidebar.filtersSection(this.#filters, this.#onFilterChange);
+ const newFiltersSection = Sidebar.filtersSection(
+ this.#filters,
+ this.#allHouses,
+ this.#onFilterChange,
+ );
if (this.#filtersSectionElement) {
this.#filtersSectionElement.replaceWith(newFiltersSection);
@@ -1143,7 +1064,7 @@ export class Sidebar {
* @param {number} max
*/
#handleHistogramClick(min, max) {
- const param = document.getElementById("color-parameter")?.value || HouseParameter.price;
+ const param = this.#houseParam;
switch (param) {
case HouseParameter.price: {
@@ -1231,8 +1152,13 @@ export class Sidebar {
new DomOptions({
children: [
Sidebar.histogramSection(this.#histogram),
- Sidebar.dataSection(this.#onColorChange, this.#onAreaColorChange),
- Sidebar.filtersSection(this.#filters, this.#onFilterChange),
+ Sidebar.dataSection(
+ this.#areaParam,
+ this.#houseParam,
+ this.#onColorChange,
+ this.#onAreaColorChange,
+ ),
+ Sidebar.filtersSection(this.#filters, this.#allHouses, this.#onFilterChange),
Sidebar.weightSection(this.#weights, this.#onWeightChange),
],
id: "sidebar-content",
@@ -1303,22 +1229,10 @@ export class Sidebar {
}
/**
- * Update district options in the multi-select
- * @param {House[]} houses
- */
- updateDistricts(houses) {
- const districtOptions = this.#renderDistrictOptions(houses);
- const districtSelect = this.#rootElement.querySelector("#district-select");
- if (districtSelect) {
- districtSelect.append(...districtOptions);
- }
- }
-
- /**
* Set the area color parameter in the dropdown
* @param {string} param
*/
- setAreaColorParameter(param) {
+ setAreaParameter(param) {
const areaColorSelect = this.#rootElement.querySelector("#area-color-parameter");
if (areaColorSelect) {
areaColorSelect.value = param;
@@ -1330,7 +1244,7 @@ export class Sidebar {
* @param {House[]} houses
* @returns {HTMLOptionElement[]}
*/
- #renderDistrictOptions(houses) {
+ static #renderDistrictOptions(houses) {
const houseDistricts = [...new Set(houses.map((h) => h.district).filter((d) => d))].sort();
return houseDistricts.map((districtName) => Dom.option(districtName, districtName));
}
@@ -1351,6 +1265,86 @@ export class Modal {
#onClearMapTimer;
/**
+ * @param {Object} options
+ * @param {House} options.house
+ * @param {boolean} options.persistent
+ * @param {object} options.positionStyles
+ * @param {() => void} options.onHide
+ * @param {() => void} options.onClearMapTimer
+ */
+ constructor(options) {
+ this.#persistent = options.persistent;
+ this.#onHide = options.onHide;
+ this.#onClearMapTimer = options.onClearMapTimer;
+
+ this.#abortController = new AbortController();
+ this.#dialog = document.createElement("dialog");
+
+ Object.assign(
+ this.#dialog.style,
+ {
+ background: "white",
+ border: "none",
+ borderRadius: "8px",
+ boxShadow: "0 4px 20px rgba(0,0,0,0.2)",
+ maxHeight: "80vh",
+ maxWidth: "600px",
+ overflowY: "auto",
+ padding: "20px",
+ position: "fixed",
+ top: "50%",
+ transform: "translateY(-50%)",
+ width: "90%",
+ zIndex: "1000",
+ },
+ options.positionStyles,
+ );
+
+ this.#dialog.append(
+ Dom.button(
+ "x",
+ () => this.hide(),
+ new DomOptions({
+ id: "close-modal-btn",
+ styles: {
+ background: "none",
+ border: "none",
+ color: "#666",
+ cursor: "pointer",
+ fontSize: "24px",
+ position: "absolute",
+ right: "10px",
+ top: "10px",
+ },
+ }),
+ ),
+ Modal.content(options.house),
+ );
+
+ // Add event listeners with AbortController
+ this.#dialog.addEventListener("close", () => this.hide(), {
+ signal: this.#abortController.signal,
+ });
+ this.#dialog.addEventListener(
+ "mouseenter",
+ () => {
+ clearTimeout(this.#timer);
+ this.#onClearMapTimer();
+ },
+ { signal: this.#abortController.signal },
+ );
+ this.#dialog.addEventListener(
+ "mouseleave",
+ () => {
+ if (!this.#persistent) {
+ this.#timer = window.setTimeout(() => this.hide(), 200);
+ }
+ },
+ { signal: this.#abortController.signal },
+ );
+ }
+
+ /**
* @param {House} house
*/
static imageSection(house) {
@@ -1562,84 +1556,6 @@ export class Modal {
return frag;
}
- /**
- * @param {House} house
- * @param {boolean} persistent
- * @param {object} positionStyles
- * @param {() => void} onHide
- * @param {() => void} onClearMapTimer
- */
- constructor(house, persistent, positionStyles, onHide, onClearMapTimer) {
- this.#persistent = persistent;
- this.#onHide = onHide;
- this.#onClearMapTimer = onClearMapTimer;
- this.#abortController = new AbortController();
- this.#dialog = document.createElement("dialog");
-
- Object.assign(
- this.#dialog.style,
- {
- background: "white",
- border: "none",
- borderRadius: "8px",
- boxShadow: "0 4px 20px rgba(0,0,0,0.2)",
- maxHeight: "80vh",
- maxWidth: "600px",
- overflowY: "auto",
- padding: "20px",
- position: "fixed",
- top: "50%",
- transform: "translateY(-50%)",
- width: "90%",
- zIndex: "1000",
- },
- positionStyles,
- );
-
- this.#dialog.append(
- Dom.button(
- "x",
- () => this.hide(),
- new DomOptions({
- id: "close-modal-btn",
- styles: {
- background: "none",
- border: "none",
- color: "#666",
- cursor: "pointer",
- fontSize: "24px",
- position: "absolute",
- right: "10px",
- top: "10px",
- },
- }),
- ),
- Modal.content(house),
- );
-
- // Add event listeners with AbortController
- this.#dialog.addEventListener("close", () => this.hide(), {
- signal: this.#abortController.signal,
- });
- this.#dialog.addEventListener(
- "mouseenter",
- () => {
- clearTimeout(this.#timer);
- this.#onClearMapTimer();
- },
- { signal: this.#abortController.signal },
- );
- this.#dialog.addEventListener(
- "mouseleave",
- () => {
- if (!this.#persistent) {
- this.#timer = window.setTimeout(() => this.hide(), 200);
- }
- },
- { signal: this.#abortController.signal },
- );
- }
-
render() {
return this.#dialog;
}
diff --git a/app/dom.js b/app/dom.js
index 9ea7bf9..9473bf1 100644
--- a/app/dom.js
+++ b/app/dom.js
@@ -80,7 +80,7 @@ export class Dom {
if (text) button.textContent = text;
for (const [k, v] of Object.entries(o.attributes)) button.setAttribute(k, v);
if (o.children) button.append(...o.children);
- if (onClick) button.addEventListener("click", onClick);
+ if (onClick) button.addEventListener("pointerdown", onClick);
return button;
}
@@ -192,13 +192,15 @@ export class Dom {
/**
* Create a `<select>`
+ * @param {string|undefined} selected
* @param {DomOptions} o
* @param { (e: Event) => void } onChange
* @returns {HTMLSelectElement}
*/
- static select(onChange, o = new DomOptions()) {
+ static select(selected = undefined, onChange, o = new DomOptions()) {
const select = document.createElement("select");
Object.assign(select.style, o.styles);
+ if (selected !== undefined) select.value = selected;
if (o.id) select.id = o.id;
for (const cls of o.classes) select.classList.add(cls);
for (const [k, v] of Object.entries(o.attributes)) select.setAttribute(k, v);
@@ -373,8 +375,8 @@ export class Dom {
// Update active range position
const updateActiveRange = () => {
- const minVal = parseInt(minSlider.value);
- const maxVal = parseInt(maxSlider.value);
+ const minVal = parseInt(minSlider.value, 10);
+ const maxVal = parseInt(maxSlider.value, 10);
const minPercent = ((minVal - min) / (max - min)) * 100;
const maxPercent = ((maxVal - min) / (max - min)) * 100;
@@ -388,41 +390,38 @@ export class Dom {
// Event listeners
minSlider.addEventListener("input", () => {
- const minVal = parseInt(minSlider.value);
- const maxVal = parseInt(maxSlider.value);
+ const minVal = parseInt(minSlider.value, 10);
+ const maxVal = parseInt(maxSlider.value, 10);
if (minVal > maxVal) {
minSlider.value = maxVal.toString();
}
updateActiveRange();
- onChange(parseInt(minSlider.value), parseInt(maxSlider.value));
+ onChange(parseInt(minSlider.value, 10), parseInt(maxSlider.value, 10));
});
maxSlider.addEventListener("input", () => {
- const minVal = parseInt(minSlider.value);
- const maxVal = parseInt(maxSlider.value);
+ const minVal = parseInt(minSlider.value, 10);
+ const maxVal = parseInt(maxSlider.value, 10);
if (maxVal < minVal) {
maxSlider.value = minVal.toString();
}
updateActiveRange();
- onChange(parseInt(minSlider.value), parseInt(maxSlider.value));
+ onChange(parseInt(minSlider.value, 10), parseInt(maxSlider.value, 10));
});
// Initial update
updateActiveRange();
// Assemble track
- track.appendChild(activeRange);
- track.appendChild(minSlider);
- track.appendChild(maxSlider);
+ track.append(activeRange, minSlider, maxSlider);
trackContainer.appendChild(track);
// Assemble container
- container.appendChild(valueDisplay);
- container.appendChild(trackContainer);
+ container.append(valueDisplay, trackContainer);
if (options.children) container.append(...options.children);
diff --git a/app/main.js b/app/main.js
index d7fb1b2..6e862c0 100644
--- a/app/main.js
+++ b/app/main.js
@@ -82,22 +82,14 @@ export class Init {
}),
);
- 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",
});
+
+ this.#initialize();
}
/**
@@ -108,17 +100,16 @@ export class Init {
// Load collection data
this.#collection = await Collection.get();
- // Pre-calculate scores for all houses
- this.#precalculateScores();
+ 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 actual data ranges
- const filters = this.#initializeFilters();
+ const filters = new Filters(this.#collection.houses);
- // Apply initial filtering
- const filteredHouses = this.#applyInitialFilters(filters);
-
- // Create app with fully initialized data
- this.#createApp(filters, filteredHouses);
+ this.#loadingElement.remove();
+ new App(this.#collection, filters);
} catch (error) {
console.error("Initialization failed:", error);
this.#showError("Failed to load application data. Please refresh the page.");
@@ -126,67 +117,10 @@ export class Init {
}
/**
- * 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(
+ static getError(message) {
+ return Dom.div(
new DomOptions({
children: [
Dom.div(
@@ -266,7 +200,14 @@ export class Init {
},
}),
);
+ }
+ /**
+ * @param {string} message
+ */
+ #showError(message) {
+ this.#loadingElement.remove();
+ const errorElement = Init.getError(message);
document.body.appendChild(errorElement);
}
}
@@ -274,8 +215,6 @@ export class Init {
export class App {
/** @type {Collection} */
#collection;
- /** @type {House[]} */
- #filtered;
/** @type {Filters} */
#filters;
/** @type {Weights} */
@@ -296,48 +235,11 @@ export class App {
/**
* @param {Collection} collection
* @param {Filters} filters
- * @param {House[]} filteredHouses
*/
- constructor(collection, filters, filteredHouses) {
+ constructor(collection, filters) {
this.#collection = collection;
this.#filters = filters;
- this.#filtered = filteredHouses;
- this.#setupLayout();
-
- this.#sidebar = new Sidebar(
- this.#collection.houses,
- this.#filters,
- this.#weights,
- () => 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) => this.#handleHouseHover(houseId, hide),
- });
-
- // 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();
-
- // 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",
@@ -345,12 +247,55 @@ export class App {
height: "100vh",
margin: "0",
});
- }
- /**
- * Render the main UI
- */
- #renderUI() {
+ this.#sidebar = new Sidebar({
+ allHouses: this.#collection.houses,
+ areaParam: this.#areaParameter,
+ filters: this.#filters,
+ houseParam: this.#houseParameter,
+ onAreaParamChange: (param) => {
+ this.#areaParameter = param;
+ this.#map.updateArea(this.#areaParameter);
+ },
+ onFilterChange: () => {
+ this.#map.updateHouseVisibility(this.#filters);
+
+ const stats = App.#createStats(this.#collection.houses, this.#filters);
+ this.#stats.replaceWith(stats);
+ this.#stats = stats;
+ },
+ onHouseParamChange: (param) => {
+ this.#houseParameter = param;
+ this.#map.updateHousesParameter(this.#houseParameter);
+ },
+ onWeightChange: (key, value) => {
+ if (key in this.#weights) {
+ this.#weights[/** @type {keyof Weights} */ (key)] = value;
+ }
+
+ for (const h of this.#collection.houses) {
+ h.scores.current = Math.round(ScoringEngine.calculate(h, this.#weights));
+ h.value = h.scores.current;
+ }
+ const stats = App.#createStats(this.#collection.houses, this.#filters);
+ this.#stats.replaceWith(stats);
+ this.#stats = stats;
+ },
+ weights: this.#weights,
+ });
+
+ this.#map = new MapEl({
+ areaParameter: this.#areaParameter,
+ collection: this.#collection,
+ houseParameter: this.#houseParameter,
+ onHouseClick: (houseId, persistent) => this.#showHouseModal(houseId, persistent),
+ onHouseHover: (houseId, hide) => {
+ hide ? this.#modal?.hide() : this.#showHouseModal(houseId, false);
+ },
+ });
+
+ this.#stats = App.#createStats(this.#collection.houses, this.#filters);
+
document.body.appendChild(
Dom.div(
new DomOptions({
@@ -381,50 +326,6 @@ export class App {
}
/**
- * 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);
- }
- }
-
- /**
* Show modal with house details
* @param {string} houseId
* @param {boolean} persistent
@@ -434,14 +335,20 @@ export class App {
if (!house) return;
this.#map.setModalPersistence(persistent);
-
- // Hide existing modal
this.#modal?.hide();
- this.#modal = new Modal(
- house,
- persistent,
- {
+ this.#modal = new Modal({
+ house: house,
+ onClearMapTimer: () => {
+ this.#map.clearModalTimer();
+ },
+ onHide: () => {
+ this.#modal = null;
+ this.#map.setModalPersistence(false);
+ this.#map.clearModalTimer();
+ },
+ persistent: persistent,
+ positionStyles: {
left: "auto",
maxHeight: "80vh",
maxWidth: "400px",
@@ -450,15 +357,7 @@ export class App {
transform: "translateY(-50%)",
width: "90%",
},
- () => {
- this.#modal = null;
- this.#map.setModalPersistence(false);
- this.#map.clearModalTimer();
- },
- () => {
- this.#map.clearModalTimer();
- },
- );
+ });
document.body.appendChild(this.#modal.render());
this.#modal.show();
@@ -467,52 +366,24 @@ export class App {
/**
* Apply filters and recalculate scores
*/
- #applyFiltersAndScoring() {
- // Recalculate all scores with current weights
- App.#recalculateScores(this.#collection.houses, this.#weights);
-
- // 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 = this.#createStats();
- this.#stats.replaceWith(stats);
- this.#stats = stats;
-
- // Update sidebar
- this.#sidebar.update(this.#filtered, this.#houseParameter);
- }
-
- /**
- * 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));
- h.value = h.scores.current;
- }
- }
+ #applyFiltersAndScoring() {}
/**
* Create statistics display
+ * @param {House[]} houses
+ * @param {Filters} filters
* @returns {HTMLElement}
*/
- #createStats() {
- const averageScore = this.#filtered.length
- ? Math.round(this.#filtered.reduce((s, h) => s + h.scores.current, 0) / this.#filtered.length)
+ static #createStats(houses, filters) {
+ const filtered = houses.filter((h) => h.matchesFilters(filters));
+ const averageScore = filtered.length
+ ? Math.round(filtered.reduce((s, h) => s + h.scores.current, 0) / filtered.length)
: 0;
return Dom.div(
new DomOptions({
children: [
- Dom.strong(this.#filtered.length.toString()),
+ Dom.strong(`${filtered.length.toString()}/${houses.length}`),
Dom.span(" houses shown • Average score: "),
Dom.strong(averageScore.toString()),
Dom.span(" • Use weights sliders to adjust scoring"),
diff --git a/app/map.js b/app/map.js
index d63da17..5976d92 100644
--- a/app/map.js
+++ b/app/map.js
@@ -1,5 +1,13 @@
import { Bounds, Feature, LineString, MultiLineString, Point } from "geom";
-import { AreaParam, Collection, District, House, HouseParameter, StatisticalArea } from "models";
+import {
+ AreaParam,
+ Collection,
+ District,
+ Filters,
+ House,
+ HouseParameter,
+ StatisticalArea,
+} from "models";
import { Svg, SvgOptions } from "svg";
/**
@@ -55,14 +63,18 @@ export class PanningConfig {
export class MapEl {
/** @type {SVGSVGElement} */
svg;
- /** @type {Collection|null} */
- #collection = null;
- /** @type {SVGGElement|null} */
- #housesGroup = null;
+ /** @type {Collection} */
+ #collection;
+ /** @type {SVGGElement} */
+ #housesGroup;
/** @type {Function} */
#onHouseClick;
/** @type {Function} */
#onHouseHover;
+ /** @type {HouseParameter} */
+ #houseParameter;
+ /** @type {AreaParam} */
+ #areaParameter;
/** @type {number|undefined} */
#modalTimer;
/** @type {boolean} */
@@ -79,8 +91,14 @@ export class MapEl {
* @param {Object} options
* @param {Function} options.onHouseClick
* @param {Function} options.onHouseHover
+ * @param {Collection} options.collection
+ * @param {HouseParameter} options.houseParameter
+ * @param {AreaParam} options.areaParameter
*/
constructor(options) {
+ this.#collection = options.collection;
+ this.#areaParameter = options.areaParameter;
+ this.#houseParameter = options.houseParameter;
const svg = Svg.svg(
new SvgOptions({
attributes: {
@@ -101,6 +119,24 @@ export class MapEl {
this.#onHouseClick = options.onHouseClick;
this.#onHouseHover = options.onHouseHover;
this.#enableControls(this.svg);
+
+ this.#setInitialViewBox(District.bounds(this.#collection.districts));
+ this.#fullBounds = Bounds.union([
+ Bounds.union(this.#collection.coastLine.features.map((f) => f.geometry.bounds())),
+ Bounds.union(this.#collection.mainRoads.features.map((f) => f.geometry.bounds())),
+ ]);
+
+ const layers = this.createMap(this.#collection, this.#areaParameter);
+ this.#housesGroup = MapEl.#createHouses({
+ houses: this.#collection.houses,
+ modalTimer: this.#modalTimer,
+ onHouseClick: this.#onHouseClick,
+ onHouseHover: this.#onHouseHover,
+ parameter: this.#houseParameter,
+ persistentModal: this.#persistentModal,
+ });
+ layers.appendChild(this.#housesGroup);
+ this.svg.append(layers);
}
/**
@@ -184,16 +220,85 @@ export class MapEl {
}
/**
- * Initialize map with empty content
+ * @param {object} o
+ * @param {House[]} o.houses
+ * @param {HouseParameter} o.parameter
+ * @param {number|undefined} o.modalTimer
+ * @param {Function} o.onHouseClick
+ * @param {Function} o.onHouseHover
+ * @param {boolean} o.persistentModal
+ * @returns {SVGGElement}
+ */
+ static #createHouses(o) {
+ const values = o.houses.map((house) => house.get(o.parameter)).sort();
+ const range = { max: Math.max(...values), min: Math.min(...values) };
+ switch (o.parameter) {
+ case HouseParameter.price: // No prices available for each house. Take some from the bottom
+ range.min = values[Math.floor(values.length * 0.2)];
+ range.max = values[Math.floor(values.length * 0.8)];
+ }
+ const housesEl = o.houses.map((house) => {
+ const normalized = MapMath.normalize(house.get(o.parameter), range.min, range.max);
+ const circle = Svg.circle(
+ house.coordinates,
+ new SvgOptions({
+ attributes: {
+ "data-id": house.id,
+ fill: Color.ocean(normalized),
+ },
+ children: [
+ Svg.title(`${house.address}, ${house.district}\n€${house.price.toLocaleString()}`),
+ ],
+ classes: ["house-marker"],
+ }),
+ );
+ circle.addEventListener("mouseenter", () => {
+ circle.setAttribute("r", "0.005");
+ clearTimeout(o.modalTimer);
+ o.onHouseHover(house.id, false);
+ });
+
+ circle.addEventListener("mouseleave", () => {
+ circle.setAttribute("r", "0.003");
+ circle.setAttribute("stroke", "rgba(51, 51, 51, 1)");
+ circle.setAttribute("stroke-width", "0.001");
+
+ if (!o.persistentModal && o.onHouseHover) {
+ o.modalTimer = window.setTimeout(() => {
+ o.onHouseHover(house.id, true);
+ }, 200);
+ }
+ });
+ circle.addEventListener("pointerdown", (e) => {
+ e.stopPropagation();
+ o.onHouseClick(house.id, true);
+ o.persistentModal = true;
+ });
+ return circle;
+ });
+
+ return Svg.g(
+ new SvgOptions({
+ attributes: {
+ "pointer-events": "visiblePainted",
+ r: "0.003",
+ stroke: "rgba(51, 51, 51, 1)",
+ "stroke-linecap": "butt",
+ "stroke-width": "0.001",
+ },
+ children: housesEl,
+ id: "houses",
+ }),
+ );
+ }
+
+ /**
* @param {Collection} c
- * @param {HouseParameter} houseParameter
* @param {AreaParam} areaParameter
- * @returns {SVGSVGElement}
+ * @returns {SVGGElement}
*/
- initialize(c, houseParameter, areaParameter) {
- this.#collection = c;
- this.#setInitialViewBox(District.bounds(c.districts));
- const transformGroup = Svg.g(
+ createMap(c, areaParameter) {
+ return Svg.g(
new SvgOptions({
attributes: { transform: "scale(1, -1)" },
children: [
@@ -291,29 +396,10 @@ export class MapEl {
id: "districts",
}),
),
- Svg.g(
- new SvgOptions({
- attributes: {
- "pointer-events": "visiblePainted",
- r: "0.003",
- stroke: "rgba(51, 51, 51, 1)",
- "stroke-linecap": "butt",
- "stroke-width": "0.001",
- },
- children: this.#getHouses(c.houses, houseParameter),
- id: "houses",
- }),
- ),
],
id: "map-transform",
}),
);
- this.svg.append(transformGroup);
- this.#fullBounds = Bounds.union([
- Bounds.union(c.coastLine.features.map((f) => f.geometry.bounds())),
- Bounds.union(c.mainRoads.features.map((f) => f.geometry.bounds())),
- ]);
- return this.svg;
}
/**
@@ -621,61 +707,6 @@ export class MapEl {
}
/**
- * Set houses data and render markers
- * @param {House[]} houses
- * @param {HouseParameter} param
- */
- #getHouses(houses, param) {
- const values = houses.map((house) => house.get(param)).sort();
- const range = { max: Math.max(...values), min: Math.min(...values) };
- switch (param) {
- case HouseParameter.price: // No prices available for each house. Take some from the bottom
- range.min = values[Math.floor(values.length * 0.2)];
- range.max = values[Math.floor(values.length * 0.8)];
- }
- return houses.map((house) => {
- const value = house.get(param);
- const normalized = MapMath.normalize(value, range.min, range.max);
- const circle = Svg.circle(
- house.coordinates,
- new SvgOptions({
- attributes: {
- "data-id": house.id,
- fill: Color.ocean(normalized),
- },
- children: [
- Svg.title(`${house.address}, ${house.district}\n€${house.price.toLocaleString()}`),
- ],
- classes: ["house-marker"],
- }),
- );
- circle.addEventListener("mouseenter", () => {
- circle.setAttribute("r", "0.005");
- clearTimeout(this.#modalTimer);
- this.#onHouseHover(house.id, false);
- });
-
- circle.addEventListener("mouseleave", () => {
- circle.setAttribute("r", "0.003");
- circle.setAttribute("stroke", "rgba(51, 51, 51, 1)");
- circle.setAttribute("stroke-width", "0.001");
-
- if (!this.#persistentModal && this.#onHouseHover) {
- this.#modalTimer = window.setTimeout(() => {
- this.#onHouseHover(house.id, true);
- }, 200);
- }
- });
- circle.addEventListener("click", (e) => {
- e.stopPropagation();
- this.#onHouseClick(house.id, true);
- this.#persistentModal = true;
- });
- return circle;
- });
- }
-
- /**
* Set districts data and render polygons
* @param {District[]} districts
*/
@@ -789,7 +820,7 @@ export class MapEl {
* Update house colors based on current color parameter
* @param {HouseParameter} param
*/
- updateHousesColor(param) {
+ updateHousesParameter(param) {
const values = this.#collection?.houses.map((house) => house.get(param)).sort();
if (!values) {
return;
@@ -845,15 +876,18 @@ export class MapEl {
/**
* Update house visibility based on filtered house IDs
- * @param {string[]} filteredHouseIds
+ * @param {Filters} filters
*/
- updateHouseVisibility(filteredHouseIds) {
- const filteredSet = new Set(filteredHouseIds);
+ updateHouseVisibility(filters) {
+ const ids = new Set(
+ this.#collection.houses.filter((h) => h.matchesFilters(filters)).map((h) => h.id),
+ );
const markers = this.#housesGroup?.querySelectorAll(".house-marker");
-
markers?.forEach((marker) => {
- const houseId = marker.id;
- marker.setAttribute("display", filteredSet.has(houseId) ? "" : "none");
+ const id = marker?.getAttribute("data-id");
+ if (id) {
+ marker.setAttribute("display", ids.has(id) ? "" : "none");
+ }
});
}
diff --git a/app/models.js b/app/models.js
index e3f6381..6ad5636 100644
--- a/app/models.js
+++ b/app/models.js
@@ -708,7 +708,10 @@ export class House {
}
export class Filters {
- constructor() {
+ /**
+ * @param {House[]} houses
+ */
+ constructor(houses) {
this.minPrice = 0;
this.maxPrice = Number.POSITIVE_INFINITY;
this.minYear = 1800;
@@ -717,46 +720,45 @@ export class Filters {
this.maxArea = Number.POSITIVE_INFINITY;
this.minLot = 0;
this.maxLot = Number.POSITIVE_INFINITY;
+ this.updateRanges(houses);
/** @type {string[]} */
this.districts = [];
}
+ reset() {
+ 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;
+ 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.minPrice = Math.min(...prices);
+ this.maxPrice = Math.max(...prices);
- this.minArea = Math.min(...areas) || 0;
- this.maxArea = Math.max(...areas) || defaultArea;
+ this.minYear = Math.min(...years);
+ this.maxYear = Math.max(...years);
- this.minLot = Math.min(...lots) || 0;
- this.maxLot = Math.max(...lots) || defaultLot;
+ this.minArea = Math.min(...areas);
+ this.maxArea = Math.max(...areas);
- // 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);
+ this.minLot = Math.min(...lots);
+ this.maxLot = Math.max(...lots);
}
}