import { Dom, DomOptions, ToastType } from "dom"; import { AreaParam, Filters, House, HouseParameter, Weights } from "models"; import { Svg, SvgOptions } from "svg"; export class Histogram { /** @type {HTMLElement} */ #rootElement; /** @type {number[]} */ #values = []; /** @type {number} */ #bins = 5; /** @type {(min: number, max: number) => void} */ #onBarClick; /** @type {string} */ #currentParameter = HouseParameter.price; /** * @param {Object} options * @param {number[]} options.values * @param {number} options.bins * @param {(min: number, max: number) => void} options.onBarClick * @param {string} options.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(); } /** * Update histogram with new data * @param {number[]} values * @param {string} parameter */ update(values, parameter) { this.#values = values.filter((v) => !Number.isNaN(v) && Number.isFinite(v)); this.#currentParameter = parameter; const newElement = this.#render(); this.#rootElement.replaceWith(newElement); this.#rootElement = newElement; } /** * Calculate percentile bins * @returns {{min: number, max: number, count: number, values: number[]}[]} */ #calculateBins() { if (this.#values.length === 0) return []; const sorted = [...this.#values].sort((a, b) => a - b); const binSize = Math.ceil(sorted.length / this.#bins); const bins = []; for (let i = 0; i < this.#bins; i++) { const startIdx = i * binSize; const endIdx = Math.min((i + 1) * binSize - 1, sorted.length - 1); if (startIdx >= sorted.length) break; const min = sorted[startIdx]; const max = sorted[endIdx]; const binValues = sorted.slice(startIdx, endIdx + 1); bins.push({ count: binValues.length, max, min, values: binValues, }); } return bins; } /** * Format value based on parameter type * @param {number} value * @returns {string} */ #formatValue(value) { switch (this.#currentParameter) { case HouseParameter.price: return `${(value / 1000).toFixed(0)}k€`; case HouseParameter.area: return `${value.toFixed(0)} m²`; case HouseParameter.year: return value.toFixed(0); case HouseParameter.score: return value.toFixed(1); default: return value.toFixed(0); } } /** * Get parameter display name * @returns {string} */ #getParameterName() { switch (this.#currentParameter) { case HouseParameter.price: return "Price"; case HouseParameter.area: return "Living Area"; case HouseParameter.year: return "Construction Year"; case HouseParameter.score: return "Score"; default: return "Value"; } } /** * Render histogram SVG using existing Svg methods * @returns {HTMLElement} */ #render() { const bins = this.#calculateBins(); if (bins.length === 0) { return Dom.div( new DomOptions({ children: [Dom.span("No data available")], styles: { color: "#666", fontSize: "0.8rem", padding: "1rem", textAlign: "center", }, }), ); } const maxCount = Math.max(...bins.map((bin) => bin.count)); const svgWidth = 280; const svgHeight = 120; const padding = { bottom: 30, left: 40, right: 10, top: 20 }; const chartWidth = svgWidth - padding.left - padding.right; const chartHeight = svgHeight - padding.top - padding.bottom; const barWidth = chartWidth / bins.length; const children = []; // Create title children.push( Svg.textXY( svgWidth / 2, 12, `${this.#getParameterName()} Distribution`, new SvgOptions({ attributes: { "font-size": "10", "font-weight": "bold", "text-anchor": "middle", }, styles: { fill: "#333", }, }), ), ); // Create bars bins.forEach((bin, index) => { const barHeight = (bin.count / maxCount) * chartHeight; const x = padding.left + index * barWidth; const y = padding.top + chartHeight - barHeight; // Create bar rectangle const bar = Svg.rectXY( x, y, barWidth - 2, barHeight, new SvgOptions({ attributes: { fill: "#4caf50", stroke: "#388e3c", "stroke-width": "0.5", }, children: [ Svg.title( `Range: ${this.#formatValue(bin.min)} - ${this.#formatValue(bin.max)}\nCount: ${bin.count} properties\nClick to filter this range`, ), ], styles: { cursor: "pointer", transition: "fill 0.2s ease", }, }), ); bar.addEventListener("mouseenter", () => { bar.setAttribute("fill", "#2e7d32"); }); bar.addEventListener("mouseleave", () => { bar.setAttribute("fill", "#4caf50"); }); bar.addEventListener("pointerdown", () => { this.#onBarClick(bin.min, bin.max); }); children.push(bar); // Create X-axis label const shortMin = this.#formatValue(bin.min).replace("k€", "k"); const shortMax = this.#formatValue(bin.max).replace("k€", "k"); const labelText = `${shortMin}-${shortMax}`; const labelX = x + barWidth / 2; let labelY = svgHeight - 8; let transform = ""; if (labelText.length > 8) { transform = `rotate(45 ${labelX} ${labelY})`; labelY = svgHeight - 12; } children.push( Svg.textXY( labelX, labelY, labelText, new SvgOptions({ attributes: { "font-size": "8", "text-anchor": "middle", transform: transform, }, styles: { fill: "#666", }, }), ), ); }); // Create Y-axis line children.push( Svg.line( padding.left, padding.top, padding.left, padding.top + chartHeight, new SvgOptions({ attributes: { stroke: "#ccc", "stroke-width": "1", }, }), ), ); // Create Y-axis labels and ticks const yStep = maxCount / 3; for (let i = 0; i <= 3; i++) { const count = Math.round(i * yStep); const y = padding.top + chartHeight - (i * chartHeight) / 3; // Y-axis label children.push( Svg.textXY( padding.left - 5, y, count.toString(), new SvgOptions({ attributes: { dy: "0.3em", "font-size": "8", "text-anchor": "end", }, styles: { fill: "#666", }, }), ), ); // Y-axis tick children.push( Svg.line( padding.left - 3, y, padding.left, y, new SvgOptions({ attributes: { stroke: "#ccc", "stroke-width": "1", }, }), ), ); } // Create main SVG element const svgElement = Svg.svg( new SvgOptions({ attributes: { height: svgHeight.toString(), width: svgWidth.toString(), }, children: children, styles: { background: "#fafafa", border: "1px solid #eee", borderRadius: "4px", }, }), ); return Dom.div( new DomOptions({ children: [svgElement], styles: { background: "white", border: "1px solid #e0e0e0", borderRadius: "4px", margin: "1rem 0", padding: "0.5rem", }, }), ); } /** * Get the root DOM element * @returns {HTMLElement} */ render() { return this.#rootElement; } } 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] */ static toast(message, type = ToastType.error) { return Dom.div( new DomOptions({ children: [Dom.p(message)], classes: ["toast", `toast-${type}`], id: "app-toast", styles: { background: type === ToastType.error ? "#f44336" : type === ToastType.warning ? "#ff9800" : "#4caf50", borderRadius: "4px", boxShadow: "0 4px 12px rgba(0,0,0,0.15)", color: "white", fontSize: "14px", fontWeight: "500", maxWidth: "300px", padding: "12px 20px", position: "fixed", right: "20px", top: "20px", transition: "all .3s ease", zIndex: "10000", }, }), ); } /** * Remove all children * @param {HTMLElement} el */ static clear(el) { while (el.firstChild) el.removeChild(el.firstChild); } /** * Create a weight slider * @param {string} id * @param {string} labelText * @param {string} weightKey * @param {number} initialValue * @param {(key: string, value: number) => void} onChange * @returns {HTMLElement} */ static slider(id, labelText, weightKey, initialValue, onChange) { const output = Dom.span( initialValue.toFixed(1), new DomOptions({ id: `${id}-value`, styles: { color: "#0066cc", fontSize: "0.85rem", fontWeight: "bold", textAlign: "center" }, }), ); return Dom.div( new DomOptions({ children: [ Dom.label( id, labelText, new DomOptions({ children: [ Dom.span( labelText, new DomOptions({ styles: { fontSize: "0.85rem" }, }), ), Dom.span(" "), output, ], }), ), Dom.input( "range", (e) => { const target = /** @type {HTMLInputElement} */ (e.target); const val = Number(target.value); output.textContent = val.toFixed(1); onChange(weightKey, val); }, "", "", new DomOptions({ attributes: { max: "1", min: "0", step: "0.1", value: initialValue.toString() }, id, styles: { margin: "0.5rem 0", width: "100%", }, }), ), ], styles: { display: "flex", flexDirection: "column", marginBottom: "1rem" }, }), ); } /** * Create a dual range filter input * @param {string} id * @param {string} labelText * @param {number} minValue * @param {number} maxValue * @param {number} currentMin * @param {number} currentMax * @param {number} step * @param {(min: number, max: number) => void} onChange * @returns {HTMLElement} */ static rangeFilter(id, labelText, minValue, maxValue, currentMin, currentMax, step, onChange) { return Dom.div( new DomOptions({ children: [ Dom.label( id, labelText, new DomOptions({ styles: { fontSize: "0.85rem", fontWeight: "bold", marginBottom: "0.25rem", }, }), ), Dom.range( minValue, maxValue, currentMin, currentMax, step, (min, max) => onChange(min, max), new DomOptions({ id, styles: { marginBottom: "1rem", }, }), ), ], styles: { display: "flex", flexDirection: "column", marginBottom: "1.75rem", }, }), ); } } export class Sidebar { /** @type {Histogram} */ #histogram; /** @type {House[]} */ #allHouses; /** @type {HTMLElement} */ #rootElement; /** @type {boolean} */ #collapsed = false; /** @type {Filters} */ #filters; /** @type {AreaParam} */ #areaParam; /** @type {HouseParameter} */ #houseParam; /** @type {Weights} */ #weights; /** @type {() => void} */ #onFilterChange; /** @type {(key: string, value: number) => void} */ #onWeightChange; /** @type {(param: string) => void} */ #onColorChange; /** @type {(param: string) => void} */ #onAreaColorChange; /** @type {HTMLElement|null} */ #filtersSectionElement; /** * @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(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(); } /** * @param {Weights} weights * @param {(key: string, value: number) => void} onChange */ static weightSection(weights, onChange) { return Dom.div( new DomOptions({ children: [ Dom.heading( 3, "Scoring Weights", new DomOptions({ styles: { color: "#333", fontSize: "1.1rem", margin: "1rem 0 1rem 0", }, }), ), // Basic house properties Widgets.slider("w-price", "Price", "price", weights.price, onChange), Widgets.slider( "w-year", "Construction Year", "constructionYear", weights.constructionYear, onChange, ), Widgets.slider("w-area", "Living Area", "livingArea", weights.livingArea, onChange), // Location factors Widgets.slider( "w-market", "Market Distance", "distanceMarket", weights.distanceMarket, onChange, ), Widgets.slider( "w-school", "School Distance", "distanceSchool", weights.distanceSchool, onChange, ), // Transit distances Widgets.slider( "w-train", "Train Distance", "distanceTrain", weights.distanceTrain, onChange, ), Widgets.slider( "w-lightrail", "Light Rail Distance", "distanceLightRail", weights.distanceLightRail, onChange, ), Widgets.slider("w-tram", "Tram Distance", "distanceTram", weights.distanceTram, onChange), // Statistical area factors Widgets.slider( "w-foreign", "Foreign Speakers", "foreignSpeakers", weights.foreignSpeakers, onChange, ), Widgets.slider( "w-unemployment", "Unemployment Rate", "unemploymentRate", weights.unemploymentRate, onChange, ), Widgets.slider( "w-income", "Average Income", "averageIncome", weights.averageIncome, onChange, ), Widgets.slider( "w-education", "Higher Education", "higherEducation", weights.higherEducation, onChange, ), ], }), ); } /** * @param {AreaParam} areaParam * @param {HouseParameter} houseParam * @param {(param: string) => void} onHouseChange * @param {(param: string) => void} onAreaChange */ static dataSection(areaParam, houseParam, onHouseChange, onAreaChange) { return Dom.div( new DomOptions({ children: [ Dom.heading( 3, "Map Colors", new DomOptions({ styles: { color: "#333", fontSize: "1.1rem", margin: "0 0 1rem 0", }, }), ), 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({ id: "color-parameter", styles: { marginBottom: "1rem", }, }), ), 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({ id: "area-color-parameter", }), ), ], styles: { borderBottom: "1px solid #eee", paddingBottom: "1rem", }, }), ); } /** * @param {Filters} filters * @param {House[]} houses * @param {() => void} onChange */ static filtersSection(filters, houses, onChange) { const priceRange = { max: filters.maxPrice, min: filters.minPrice, step: 10000, }; const yearRange = { max: filters.maxYear, min: filters.minYear, step: 1, }; const areaRange = { max: filters.maxArea, min: filters.minArea, step: 10, }; const lotRange = { max: filters.maxArea, min: filters.minArea, step: 100, }; return Dom.div( new DomOptions({ children: [ Dom.heading( 3, "Filters", new DomOptions({ styles: { color: "#333", fontSize: "1.1rem", margin: "1rem 0 1rem 0", }, }), ), 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({ id: "price-range", }), ), 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({ id: "year-range", }), ), 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({ id: "area-range", }), ), 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({ id: "lot-range", }), ), // Districts Multi-select Dom.div( new DomOptions({ children: [ Dom.label( "district-select", "Districts", new DomOptions({ styles: { fontSize: "0.85rem", fontWeight: "bold", marginBottom: "0.25rem", }, }), ), Dom.select( undefined, (e) => { const target = /** @type {HTMLSelectElement} */ (e.target); const selectedOptions = Array.from(target.selectedOptions).map( (opt) => opt.value, ); filters.districts = selectedOptions; onChange(); }, new DomOptions({ attributes: { multiple: "true" }, children: [...Sidebar.#renderDistrictOptions(houses)], id: "district-select", styles: { border: "1px solid #ddd", borderRadius: "4px", minHeight: "120px", padding: "0.5rem", width: "100%", }, }), ), ], styles: { display: "flex", flexDirection: "column", }, }), ), // Clear Filters Button Dom.button( "Clear All Filters", () => { // Reset all ranges to their maximum possible values filters.reset(); // Update the UI by triggering onChange onChange(); }, new DomOptions({ styles: { background: "#f44336", border: "none", borderRadius: "4px", color: "white", cursor: "pointer", fontSize: "0.85rem", marginTop: "1rem", padding: "0.5rem 1rem", width: "100%", }, }), ), ], styles: { borderBottom: "1px solid #eee", paddingBottom: "1rem", }, }), ); } /** * Update sidebar with new data * @param {HouseParameter} houseParam */ 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(houses) { if (!houses || houses.length === 0) return; const newFiltersSection = Sidebar.filtersSection( this.#filters, this.#allHouses, this.#onFilterChange, ); if (this.#filtersSectionElement) { this.#filtersSectionElement.replaceWith(newFiltersSection); } this.#filtersSectionElement = newFiltersSection; } /** * Handle histogram bar click * @param {number} min * @param {number} max */ #handleHistogramClick(min, max) { const param = this.#houseParam; switch (param) { case HouseParameter.price: { this.#filters.minPrice = min; this.#filters.maxPrice = max; break; } case HouseParameter.area: { this.#filters.minArea = min; this.#filters.maxArea = max; break; } case HouseParameter.year: { this.#filters.minYear = min; this.#filters.maxYear = max; break; } case HouseParameter.score: { // Handle score filtering if needed console.log(`Score range: ${min} - ${max}`); break; } } // Trigger the filter change to update the application this.#onFilterChange(); } /** * @param {Histogram} histogram */ static histogramSection(histogram) { return Dom.div( new DomOptions({ children: [ Dom.heading( 3, "Distribution", new DomOptions({ styles: { color: "#333", fontSize: "1.1rem", margin: "0 0 0.5rem 0", }, }), ), histogram.render(), ], styles: { borderBottom: "1px solid #eee", paddingBottom: "1rem", }, }), ); } /** * Render sidebar container * @returns {HTMLElement} */ #render() { return Dom.div( new DomOptions({ children: [ // Toggle button Dom.button( "☰", () => this.toggle(), new DomOptions({ id: "sidebar-toggle", styles: { background: "none", border: "none", color: "#333", cursor: "pointer", fontSize: "1.5rem", padding: "0.5rem", position: "absolute", right: "0.5rem", top: "0.5rem", zIndex: "10", }, }), ), Dom.div( new DomOptions({ children: [ Sidebar.histogramSection(this.#histogram), 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", }), ), ], id: "sidebar", styles: { background: "#fff", borderRight: "1px solid #ddd", display: "flex", flexDirection: "column", flexShrink: "0", overflowY: "auto", padding: "1rem", position: "relative", transition: "width 0.3s ease", width: "300px", }, }), ); } /** * Get the root DOM element * @returns {HTMLElement} */ render() { return this.#rootElement; } /** Show the sidebar */ show() { if (this.#collapsed) { this.toggle(); } } /** Hide the sidebar */ hide() { if (!this.#collapsed) { this.toggle(); } } toggle() { this.#collapsed = !this.#collapsed; const sidebarContent = this.#rootElement.querySelector("#sidebar-content"); const toggleButton = this.#rootElement.querySelector("#sidebar-toggle"); if (this.#collapsed) { this.#rootElement.style.width = "0"; this.#rootElement.style.padding = "0"; if (sidebarContent) sidebarContent.style.display = "none"; if (toggleButton) { toggleButton.textContent = "☰"; toggleButton.style.right = "0.5rem"; } } else { this.#rootElement.style.width = "300px"; this.#rootElement.style.padding = "1rem"; if (sidebarContent) sidebarContent.style.display = "block"; if (toggleButton) { toggleButton.textContent = "☰"; toggleButton.style.right = "0.5rem"; } } } /** * Set the area color parameter in the dropdown * @param {string} param */ setAreaParameter(param) { const areaColorSelect = this.#rootElement.querySelector("#area-color-parameter"); if (areaColorSelect) { areaColorSelect.value = param; } } /** * Render district options for multi-select * @param {House[]} houses * @returns {HTMLOptionElement[]} */ static #renderDistrictOptions(houses) { const houseDistricts = [...new Set(houses.map((h) => h.district).filter((d) => d))].sort(); return houseDistricts.map((districtName) => Dom.option(districtName, districtName)); } } export class Modal { /** @type {HTMLDialogElement} */ #dialog; /** @type {AbortController} */ #abortController; /** @type {number | undefined} */ #timer; /** @type {boolean} */ #persistent; /** @type {() => void} */ #onHide; /** @type {() => void} */ #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) { return Dom.div( new DomOptions({ children: [ Dom.span( "Images", new DomOptions({ styles: { fontSize: "14px", fontWeight: "bold", marginBottom: "10px", }, }), ), Dom.div( new DomOptions({ children: house.images.slice(0, 3).map((src) => { // Wrap image in anchor tag that opens in new tab return Dom.a( src, new DomOptions({ attributes: { rel: "noopener noreferrer", target: "_blank", }, children: [ Dom.img( src, new DomOptions({ attributes: { alt: "House image", loading: "lazy", }, styles: { borderRadius: "4px", cursor: "pointer", flexShrink: "0", height: "100px", transition: "opacity 0.2s ease", }, }), ), ], styles: { display: "block", textDecoration: "none", }, }), ); }), styles: { display: "flex", gap: "10px", overflowX: "auto", paddingBottom: "5px", }, }), ), ], styles: { marginBottom: "20px" }, }), ); } /** * Build modal content for a house * @param {House} house * @returns {DocumentFragment} */ static content(house) { const frag = document.createDocumentFragment(); frag.appendChild( Dom.div( new DomOptions({ children: [ Dom.heading( 2, house.address, new DomOptions({ styles: { color: "#333", fontSize: "20px", margin: "0" }, }), ), Dom.span( `Score: ${house.scores.current.toFixed(1)}`, new DomOptions({ styles: { background: "#e8f5e9", borderRadius: "4px", color: "#2e7d32", fontSize: "16px", fontWeight: "bold", padding: "4px 8px", }, }), ), ], id: "modal-header", styles: { alignItems: "center", display: "flex", justifyContent: "space-between", marginBottom: "20px", }, }), ), ); const grid = Dom.div( new DomOptions({ styles: { display: "grid", gap: "15px", gridTemplateColumns: "repeat(2,1fr)", marginBottom: "20px", }, }), ); const details = [ { label: "Price", value: `${house.price} €` }, { label: "Building Type", value: house.buildingType }, { label: "Construction Year", value: house.constructionYear?.toString() ?? "N/A" }, { label: "Living Area", value: `${house.livingArea} m²` }, { label: "District", value: house.district }, { label: "Rooms", value: house.rooms?.toString() ?? "N/A" }, { label: "Lot Size", value: house.totalArea ? `${house.totalArea} m²` : "N/A" }, { label: "Price per m²", value: house.pricePerSqm ? `${house.pricePerSqm} €` : "N/A" }, ]; for (const { label, value } of details) { const item = Dom.div( new DomOptions({ children: [ Dom.span( label, new DomOptions({ styles: { fontSize: "14px", fontWeight: "bold", marginBottom: "4px", marginRight: "4px", }, }), ), Dom.span(value, new DomOptions({ styles: { color: "#333", fontSize: "14px" } })), ], }), ); grid.appendChild(item); } frag.appendChild(grid); frag.appendChild( Dom.div( new DomOptions({ children: [ Dom.span( "Description", new DomOptions({ styles: { fontSize: "14px", fontWeight: "bold", marginBottom: "5px" }, }), ), Dom.p( house.description ?? "No description available.", new DomOptions({ styles: { color: "#333", fontSize: "14px", lineHeight: "1.4", marginTop: "5px" }, }), ), ], styles: { marginBottom: "20px" }, }), ), ); frag.appendChild( Dom.div( new DomOptions({ children: [ Dom.span( "Official Listing", new DomOptions({ styles: { fontSize: "14px", fontWeight: "bold", marginBottom: "5px", marginRight: "10px", }, }), ), Dom.a( house.url, new DomOptions({ attributes: { rel: "noopener noreferrer", target: "_blank", }, styles: { color: "#0066cc", fontSize: "14px", wordBreak: "break-all" }, }), "Oikotie", ), ], styles: { marginBottom: "20px" }, }), ), ); if (house.images?.length) { frag.appendChild(Modal.imageSection(house)); } return frag; } render() { return this.#dialog; } show() { if (this.#persistent) { this.#dialog.showModal(); } else { this.#dialog.show(); } } hide() { clearTimeout(this.#timer); this.#dialog.close(); this.#dialog.remove(); this.#abortController.abort(); this.#onHide(); } }