diff options
| author | Petri Hienonen <petri.hienonen@gmail.com> | 2025-11-14 21:39:29 +0200 |
|---|---|---|
| committer | Petri Hienonen <petri.hienonen@gmail.com> | 2025-11-15 14:03:04 +0200 |
| commit | 64acc82b9634d948517ec5bb2ebe5a33cdf22df6 (patch) | |
| tree | 0c82b618fa398caa2abcebeb573ac85ba29be3ef /app | |
| parent | 55085dae685305d24c29b60b1c16fc7dc76831af (diff) | |
| download | housing-64acc82b9634d948517ec5bb2ebe5a33cdf22df6.tar.zst | |
Cleanup
Diffstat (limited to 'app')
| -rw-r--r-- | app/components.js | 812 | ||||
| -rw-r--r-- | app/dom.js | 29 | ||||
| -rw-r--r-- | app/main.js | 307 | ||||
| -rw-r--r-- | app/map.js | 220 | ||||
| -rw-r--r-- | app/models.js | 48 |
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; } @@ -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"), @@ -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); } } |
