diff options
Diffstat (limited to '')
| -rw-r--r-- | app/components.js | 812 |
1 files changed, 364 insertions, 448 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; } |
