import { Dom, 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({ children: [Dom.span({ text: "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({ 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 {object} o * @param {string} o.label - Filter label * @param {number} o.min - Minimum value * @param {number} o.max - Maximum value * @param {number} o.currentMin - Current minimum value * @param {number} o.currentMax - Current maximum value * @param {(min: number, max: number) => void} o.onChange - Change callback * @param {number} o.step - Step size (default: 1) * @returns {HTMLElement} */ static range(o) { const id = `range-${o.label.toLowerCase().replace(/\s+/g, "-")}`; return Dom.div({ children: [ Dom.label({ styles: { fontSize: "0.85rem", fontWeight: "bold", marginBottom: "0.25rem", }, text: o.label, to: id, }), Dom.range({ currentMax: o.currentMax, currentMin: o.currentMin, id: id, max: o.max, min: o.min, onChange: o.onChange, step: o.step, styles: { marginBottom: "1.5rem", }, }), ], styles: { display: "flex", flexDirection: "column", }, }); } /** * Create a dropdown (select) component with label * @param {object} o * @param {string} o.label - Label text * @param {Array<{value: string, text: string}>} o.options - Dropdown options * @param {string} o.defaultValue - Default selected value * @param {(value: string) => void} o.onChange - Change callback * @returns {HTMLDivElement} */ static dropdown(o) { const selectId = `dropdown-${Math.random().toString(36).substr(2, 9)}`; return Dom.div({ children: [ Dom.label({ styles: { fontSize: "0.85rem", fontWeight: "bold", marginBottom: "0.25rem", }, text: o.label, to: selectId, }), Dom.select({ children: o.options.map((opt) => Dom.option({ selected: opt.value === o.defaultValue, text: opt.text, value: opt.value, }), ), id: selectId, onChange: (e) => { const target = /** @type {HTMLSelectElement} */ (e.target); o.onChange(target.value); }, selected: o.defaultValue, styles: { border: "1px solid #ddd", borderRadius: "4px", fontSize: "0.9rem", padding: "0.5rem", width: "100%", }, }), ], styles: { display: "flex", flexDirection: "column", marginBottom: "1rem", }, }); } /** * Show toast notification * @param {string} message * @param {ToastType} [type=ToastType.error] */ static toast(message, type = ToastType.error) { return Dom.div({ children: [Dom.p({ text: 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({ id: `${id}-value`, styles: { color: "#0066cc", fontSize: "0.85rem", fontWeight: "bold", textAlign: "center" }, text: initialValue.toFixed(1), }); return Dom.div({ children: [ Dom.label({ children: [ Dom.span({ styles: { fontSize: "0.85rem" }, text: labelText, }), Dom.span({ text: " " }), output, ], to: id, }), Dom.input({ attributes: { max: "1", min: "0", step: "0.1" }, id, onChange: (e) => { const target = /** @type {HTMLInputElement} */ (e.target); const val = Number(target.value); output.textContent = val.toFixed(1); onChange(weightKey, val); }, styles: { margin: "0.5rem 0", width: "100%", }, type: "range", value: initialValue.toString(), }), ], 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({ children: [ Dom.label({ styles: { fontSize: "0.85rem", fontWeight: "bold", marginBottom: "0.25rem", }, text: labelText, to: id, }), Dom.range({ currentMax, currentMin, id, max: maxValue, min: minValue, onChange: (min, max) => onChange(min, max), step, styles: { marginBottom: "1rem", }, }), ], styles: { display: "flex", flexDirection: "column", marginBottom: "1.75rem", }, }); } } export class Card { /** * Create a house card for the bottom bar * @param {House} house * @returns {HTMLElement} */ static create(house) { return Dom.div({ children: [ Dom.div({ children: house.images.slice(0, 2).map((src, index) => Dom.img({ attributes: { alt: `House image ${index + 1}`, loading: "lazy", }, src, styles: { borderRadius: "4px", cursor: "pointer", flexShrink: "0", height: "60px", objectFit: "cover", width: "60px", }, }), ), styles: { display: "flex", gap: "4px", justifyContent: "center", marginBottom: "8px", }, }), // Basic info Dom.div({ children: [ Dom.span({ styles: { fontSize: "0.8rem", fontWeight: "bold", marginBottom: "2px", }, text: `${(house.price / 1000).toFixed(0)}k€`, }), Dom.span({ styles: { color: "#666", fontSize: "0.7rem", }, text: `${house.livingArea}m² • ${house.district || "N/A"}`, }), Dom.span({ styles: { color: "#2e7d32", fontSize: "0.7rem", fontWeight: "bold", }, text: `Score: ${house.scores.current}`, }), ], styles: { display: "flex", flexDirection: "column", textAlign: "center", }, }), ], styles: { background: "white", border: "1px solid #e0e0e0", borderRadius: "8px", cursor: "pointer", display: "flex", flexDirection: "column", flexShrink: "0", height: "140px", justifyContent: "space-between", padding: "8px", transition: "all 0.2s ease", width: "120px", }, }); } } export class BottomBar { /** @type {HTMLElement} */ #rootElement; /** @type {HTMLElement} */ #scrollContainer; /** @type {House[]} */ #houses = []; /** @type {boolean} */ #expanded = false; /** @type {number} */ #visibleCards = 0; /** @type {Function} */ #onHouseClick; /** @type {number} */ #scrollPosition = 0; /** @type {boolean} */ #isDragging = false; /** @type {number} */ #startX = 0; /** * @param {Object} options * @param {House[]} options.houses * @param {(houseId: string) => void} options.onHouseClick */ constructor(options) { this.#houses = options.houses || []; this.#onHouseClick = options.onHouseClick; this.#rootElement = this.#render(); this.#calculateVisibleCards(); this.#renderCards(); // Add resize listener to recalculate visible cards window.addEventListener("resize", () => { this.#calculateVisibleCards(); this.#renderCards(); }); } /** * Calculate how many cards can be visible based on viewport width */ #calculateVisibleCards() { const cardWidth = 120; // card width + margin const containerWidth = this.#scrollContainer?.clientWidth || window.innerWidth; this.#visibleCards = Math.max(3, Math.floor(containerWidth / cardWidth)); } /** * Render cards with infinite scroll pattern */ #renderCards() { if (!this.#scrollContainer) return; // Clear existing cards while (this.#scrollContainer.firstChild) { this.#scrollContainer.removeChild(this.#scrollContainer.firstChild); } // Create cards for visible houses this.#getVisibleHouses().forEach((house) => { const card = Card.create(house); // Add click handler to open images in new tab card.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); this.#onHouseClick(house.id); // Open first image in new tab if (house.images.length > 0) { window.open(house.images[0], "_blank"); } }); this.#scrollContainer.appendChild(card); }); } /** * Get houses to display based on scroll position (infinite scroll simulation) * @returns {House[]} */ #getVisibleHouses() { // For true infinite scroll, you'd implement virtual scrolling // For now, we'll just return a subset based on visible cards count return this.#houses.slice(0, this.#visibleCards * 3); // Show 3x visible cards for smooth scrolling } /** * Handle scroll container mouse events for drag scrolling */ #setupScrollBehavior() { if (!this.#scrollContainer) return; this.#scrollContainer.addEventListener("mousedown", (e) => { this.#isDragging = true; this.#startX = e.pageX - this.#scrollContainer.offsetLeft; this.#scrollContainer.style.cursor = "grabbing"; e.preventDefault(); }); this.#scrollContainer.addEventListener("mousemove", (e) => { if (!this.#isDragging) return; e.preventDefault(); const x = e.pageX - this.#scrollContainer.offsetLeft; const walk = (x - this.#startX) * 2; this.#scrollContainer.scrollLeft = this.#scrollPosition - walk; }); this.#scrollContainer.addEventListener("mouseup", () => { this.#isDragging = false; this.#scrollContainer.style.cursor = "grab"; this.#scrollPosition = this.#scrollContainer.scrollLeft; }); this.#scrollContainer.addEventListener("mouseleave", () => { this.#isDragging = false; this.#scrollContainer.style.cursor = "grab"; }); this.#scrollContainer.addEventListener("scroll", () => { this.#scrollPosition = this.#scrollContainer.scrollLeft; // Implement infinite scroll loading here if needed }); } /** * Render the complete bottom bar * @returns {HTMLElement} */ #render() { this.#scrollContainer = Dom.div({ styles: { display: "flex", gap: "8px", overflowX: "auto", padding: "8px", scrollbarWidth: "none", }, }); this.#scrollContainer.addEventListener("wheel", (e) => { e.preventDefault(); this.#scrollContainer.scrollLeft += e.deltaY; }); // Setup drag scrolling after DOM is created setTimeout(() => this.#setupScrollBehavior(), 0); return Dom.div({ children: [ // Toggle button Dom.button({ onClick: () => this.toggle(), styles: { background: "#fff", border: "none", borderBottom: "1px solid #e0e0e0", borderRadius: "4px 4px 0 0", cursor: "pointer", fontSize: "0.8rem", fontWeight: "bold", padding: "4px 12px", position: "absolute", right: "10px", top: "-25px", zIndex: "1", }, text: this.#expanded ? "▼ Hide Results" : "▲ Top Results", }), // Scroll container this.#scrollContainer, ], styles: { background: "#f5f5f5", borderTop: "1px solid #ddd", bottom: "0", height: this.#expanded ? "160px" : "0", left: "0", overflow: "hidden", position: "fixed", transition: "height 0.3s ease", width: "100%", zIndex: "1000", }, }); } /** * Update houses and re-render * @param {House[]} houses */ updateHouses(houses) { this.#houses = houses; this.#renderCards(); } /** * Get the root DOM element * @returns {HTMLElement} */ render() { return this.#rootElement; } /** Toggle bottom bar visibility */ toggle() { this.#expanded = !this.#expanded; this.#rootElement.style.height = this.#expanded ? "160px" : "0"; // Update toggle button text const button = this.#rootElement.querySelector("button"); if (button) { button.textContent = this.#expanded ? "▼ Hide Results" : "▲ Top Results"; } if (this.#expanded) { this.#calculateVisibleCards(); this.#renderCards(); } } /** Show the bottom bar */ show() { if (!this.#expanded) { this.toggle(); } } /** Hide the bottom bar */ hide() { if (this.#expanded) { this.toggle(); } } /** Check if bottom bar is expanded */ isExpanded() { return this.#expanded; } } export class LeftSidebar { /** @type {HTMLElement} */ #rootElement; /** @type {Histogram} */ #histogram; /** @type {House[]} */ #allHouses; /** @type {Filters} */ #filters; /** @type {AreaParam} */ #areaParam; /** @type {HouseParameter} */ #houseParam; /** @type {boolean} */ #collapsed = true; /** @type {() => void} */ #onFilterChange; /** @type {(param: string) => void} */ #onColorChange; /** @type {(param: string) => void} */ #onAreaColorChange; /** * @param {Object} options * @param {House[]} options.allHouses * @param {AreaParam} options.areaParam * @param {HouseParameter} options.houseParam * @param {Filters} options.filters * @param {() => void} options.onFilterChange * @param {(param: string) => void} options.onColorChange * @param {(param: string) => void} options.onAreaColorChange */ constructor(options) { this.#areaParam = options.areaParam; this.#houseParam = options.houseParam; this.#allHouses = options.allHouses; this.#filters = options.filters; this.#onFilterChange = options.onFilterChange; this.#onColorChange = options.onColorChange; this.#onAreaColorChange = options.onAreaColorChange; 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.#rootElement = this.#render(); } /** * 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; } } this.#onFilterChange(); } /** * Update histogram when house parameter changes * @param {HouseParameter} houseParam */ #updateHistogram(houseParam) { this.#houseParam = houseParam; const values = this.#allHouses.map((house) => house.get(houseParam)); this.#histogram.update(values, houseParam); } /** * Render sidebar content * @returns {HTMLElement} */ #renderContent() { return Dom.div({ children: [ // Histogram section Dom.div({ children: [ Dom.heading({ level: 3, styles: { color: "#333", fontSize: "1.1rem", margin: "0 0 0.5rem 0", }, text: "Distribution", }), this.#histogram.render(), ], styles: { borderBottom: "1px solid #eee", paddingBottom: "1rem", }, }), // Data visualization parameters Dom.div({ children: [ Dom.heading({ level: 3, styles: { color: "#333", fontSize: "1.1rem", margin: "0 0 1rem 0", }, text: "Visualisation parameters", }), Widgets.dropdown({ defaultValue: this.#houseParam, label: "Color houses by", onChange: (value) => { this.#onColorChange(value); this.#updateHistogram(value); }, options: [ { text: "Price", value: HouseParameter.price }, { text: "Score", value: HouseParameter.score }, { text: "Construction Year", value: HouseParameter.year }, { text: "Living Area", value: HouseParameter.area }, ], }), Widgets.dropdown({ defaultValue: this.#areaParam, label: "Color areas by", onChange: (value) => this.#onAreaColorChange(value), options: [ { 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 }, ], }), ], styles: { borderBottom: "1px solid #eee", paddingBottom: "1rem", }, }), // Filters section Dom.div({ children: [ Dom.heading({ level: 3, styles: { color: "#333", fontSize: "1.1rem", margin: "1rem 0 1rem 0", }, text: "Filters", }), // Price filter Widgets.range({ currentMax: this.#filters.maxPrice, currentMin: this.#filters.minPrice, label: "Price Range (€)", max: this.#filters.maxPrice, min: this.#filters.minPrice, onChange: (min, max) => { this.#filters.minPrice = min; this.#filters.maxPrice = max === this.#filters.maxPrice ? Number.POSITIVE_INFINITY : max; this.#onFilterChange(); }, step: 10000, }), // Construction year filter Widgets.range({ currentMax: this.#filters.maxYear, currentMin: this.#filters.minYear, label: "Construction Year", max: this.#filters.maxYear, min: this.#filters.minYear, onChange: (min, max) => { this.#filters.minYear = min; this.#filters.maxYear = max === this.#filters.maxYear ? Number.POSITIVE_INFINITY : max; this.#onFilterChange(); }, step: 1, }), // Living area filter Widgets.range({ currentMax: this.#filters.maxArea, currentMin: this.#filters.minArea, label: "Living Area (m²)", max: this.#filters.maxArea, min: this.#filters.minArea, onChange: (min, max) => { this.#filters.minArea = min; this.#filters.maxArea = max === this.#filters.maxArea ? Number.POSITIVE_INFINITY : max; this.#onFilterChange(); }, step: 10, }), // Lot size filter Widgets.range({ currentMax: this.#filters.maxLot, currentMin: this.#filters.minLot, label: "Lot Size (m²)", max: this.#filters.maxLot, min: this.#filters.minLot, onChange: (min, max) => { this.#filters.minLot = min; this.#filters.maxLot = max === this.#filters.maxLot ? Number.POSITIVE_INFINITY : max; this.#onFilterChange(); }, step: 100, }), // Districts Multi-select Dom.div({ children: [ Dom.label({ styles: { fontSize: "0.85rem", fontWeight: "bold", marginBottom: "0.25rem", }, text: "Districts", to: "district-select", }), Dom.select({ attributes: { multiple: "true" }, children: [...this.#renderDistrictOptions()], id: "district-select", onChange: (e) => { const target = /** @type {HTMLSelectElement} */ (e.target); const selectedOptions = Array.from(target.selectedOptions).map( (opt) => opt.value, ); this.#filters.districts = selectedOptions; this.#onFilterChange(); }, styles: { border: "1px solid #ddd", borderRadius: "4px", minHeight: "120px", padding: "0.5rem", width: "100%", }, }), ], styles: { display: "flex", flexDirection: "column", }, }), // Clear Filters Button Dom.button({ onClick: () => { this.#filters.reset(); this.#onFilterChange(); }, styles: { background: "#f44336", border: "none", borderRadius: "4px", color: "white", cursor: "pointer", fontSize: "0.85rem", marginTop: "1rem", padding: "0.5rem 1rem", width: "100%", }, text: "Clear All Filters", }), ], styles: { borderBottom: "1px solid #eee", paddingBottom: "1rem", }, }), ], id: "left-sidebar-content", styles: { display: this.#collapsed ? "none" : "block", height: "100%", overflowY: "auto", }, }); } /** * Render district options for multi-select * @returns {HTMLOptionElement[]} */ #renderDistrictOptions() { const houseDistricts = [ ...new Set(this.#allHouses.map((h) => h.district).filter((d) => d)), ].sort(); return houseDistricts.map((districtName) => Dom.option({ text: districtName, value: districtName, }), ); } /** * Render the complete sidebar * @returns {HTMLElement} */ #render() { return Dom.div({ children: [ // Toggle button Dom.button({ id: "left-sidebar-toggle", onClick: () => this.toggle(), styles: { background: "none", border: "none", color: "#333", cursor: "pointer", fontSize: "1.5rem", left: "0.5rem", padding: "0.5rem", position: "absolute", top: "0.5rem", zIndex: "10", }, text: "☰", }), this.#renderContent(), ], id: "left-sidebar", styles: { background: "#fff", borderRight: "1px solid #ddd", display: "flex", flexDirection: "column", flexShrink: "0", height: "100%", overflowY: "auto", padding: this.#collapsed ? "0" : "1rem", position: "relative", transition: "width 0.3s ease, padding 0.3s ease", width: this.#collapsed ? "0" : "300px", }, }); } /** * Get the root DOM element * @returns {HTMLElement} */ render() { return this.#rootElement; } /** Toggle sidebar visibility */ toggle() { this.#collapsed = !this.#collapsed; const sidebarContent = this.#rootElement.querySelector("#left-sidebar-content"); if (this.#collapsed) { this.#rootElement.style.width = "0"; this.#rootElement.style.padding = "0"; if (sidebarContent) sidebarContent.style.display = "none"; } else { this.#rootElement.style.width = "300px"; this.#rootElement.style.padding = "1rem"; if (sidebarContent) sidebarContent.style.display = "block"; } } /** Show the sidebar */ show() { if (this.#collapsed) { this.toggle(); } } /** Hide the sidebar */ hide() { if (!this.#collapsed) { this.toggle(); } } } export class RightSidebar { /** @type {HTMLElement} */ #rootElement; /** @type {Weights} */ #weights; /** @type {boolean} */ #collapsed = true; /** @type {(key: string, value: number) => void} */ #onWeightChange; /** * @param {Object} options * @param {Weights} options.weights * @param {(key: string, value: number) => void} options.onWeightChange */ constructor(options) { this.#weights = options.weights; this.#onWeightChange = options.onWeightChange; this.#rootElement = this.#render(); } /** * Render weights section * @returns {HTMLElement} */ #renderWeights() { return Dom.div({ children: [ Dom.heading({ level: 3, styles: { color: "#333", fontSize: "1.1rem", margin: "1rem 0 1rem 0", }, text: "Scoring Weights", }), // Basic house properties Widgets.slider("w-price", "Price", "price", this.#weights.price, this.#onWeightChange), Widgets.slider( "w-year", "Construction Year", "constructionYear", this.#weights.constructionYear, this.#onWeightChange, ), Widgets.slider( "w-area", "Living Area", "livingArea", this.#weights.livingArea, this.#onWeightChange, ), // Location factors Widgets.slider( "w-market", "Market Distance", "distanceMarket", this.#weights.distanceMarket, this.#onWeightChange, ), Widgets.slider( "w-school", "School Distance", "distanceSchool", this.#weights.distanceSchool, this.#onWeightChange, ), // Transit distances Widgets.slider( "w-train", "Train Distance", "distanceTrain", this.#weights.distanceTrain, this.#onWeightChange, ), Widgets.slider( "w-lightrail", "Light Rail Distance", "distanceLightRail", this.#weights.distanceLightRail, this.#onWeightChange, ), Widgets.slider( "w-tram", "Tram Distance", "distanceTram", this.#weights.distanceTram, this.#onWeightChange, ), // Statistical area factors Widgets.slider( "w-foreign", "Foreign Speakers", "foreignSpeakers", this.#weights.foreignSpeakers, this.#onWeightChange, ), Widgets.slider( "w-unemployment", "Unemployment Rate", "unemploymentRate", this.#weights.unemploymentRate, this.#onWeightChange, ), Widgets.slider( "w-income", "Average Income", "averageIncome", this.#weights.averageIncome, this.#onWeightChange, ), Widgets.slider( "w-education", "Higher Education", "higherEducation", this.#weights.higherEducation, this.#onWeightChange, ), ], }); } /** * Render the complete sidebar * @returns {HTMLElement} */ #render() { return Dom.div({ children: [ // Toggle button Dom.button({ id: "right-sidebar-toggle", onClick: () => this.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", }, text: "⚙️", }), Dom.div({ children: [this.#renderWeights()], id: "right-sidebar-content", styles: { display: this.#collapsed ? "none" : "block", height: "100%", overflowY: "auto", }, }), ], id: "right-sidebar", styles: { background: "#fff", borderLeft: "1px solid #ddd", display: "flex", flexDirection: "column", flexShrink: "0", height: "100%", overflowY: "auto", padding: this.#collapsed ? "0" : "1rem", position: "relative", transition: "width 0.3s ease, padding 0.3s ease", width: this.#collapsed ? "0" : "300px", }, }); } /** * Get the root DOM element * @returns {HTMLElement} */ render() { return this.#rootElement; } /** Toggle sidebar visibility */ toggle() { this.#collapsed = !this.#collapsed; const sidebarContent = this.#rootElement.querySelector("#right-sidebar-content"); if (this.#collapsed) { this.#rootElement.style.width = "0"; this.#rootElement.style.padding = "0"; if (sidebarContent) sidebarContent.style.display = "none"; } else { this.#rootElement.style.width = "300px"; this.#rootElement.style.padding = "1rem"; if (sidebarContent) sidebarContent.style.display = "block"; } } /** Show the sidebar */ show() { if (this.#collapsed) { this.toggle(); } } /** Hide the sidebar */ hide() { if (!this.#collapsed) { this.toggle(); } } } 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({ id: "close-modal-btn", onClick: () => this.remove(), styles: { background: "none", border: "none", color: "#666", cursor: "pointer", fontSize: "24px", position: "absolute", right: "10px", top: "10px", }, text: "x", }), Modal.content(options.house), ); // Add event listeners with AbortController this.#dialog.addEventListener("close", () => this.remove(), { 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.remove(), 200); } }, { signal: this.#abortController.signal }, ); } /** * @param {House} house */ static imageSection(house) { return Dom.div({ children: [ Dom.span({ styles: { fontSize: "14px", fontWeight: "bold", marginBottom: "10px", }, text: "Images", }), Dom.div({ children: house.images.slice(0, 3).map((src) => { // Wrap image in anchor tag that opens in new tab return Dom.a({ attributes: { rel: "noopener noreferrer", target: "_blank", }, children: [ Dom.img({ attributes: { alt: "House image", loading: "lazy", }, src, styles: { borderRadius: "4px", cursor: "pointer", flexShrink: "0", height: "100px", transition: "opacity 0.2s ease", }, }), ], styles: { display: "block", textDecoration: "none", }, url: src, }); }), 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({ children: [ Dom.heading({ level: 2, styles: { color: "#333", fontSize: "20px", margin: "0" }, text: house.address, }), Dom.span({ styles: { background: "#e8f5e9", borderRadius: "4px", color: "#2e7d32", fontSize: "16px", fontWeight: "bold", padding: "4px 8px", }, text: `Score: ${house.scores.current.toFixed(1)}`, }), ], id: "modal-header", styles: { alignItems: "center", display: "flex", justifyContent: "space-between", marginBottom: "20px", }, }), ); const grid = Dom.div({ 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({ children: [ Dom.span({ styles: { fontSize: "14px", fontWeight: "bold", marginBottom: "4px", marginRight: "4px", }, text: label, }), Dom.span({ styles: { color: "#333", fontSize: "14px" }, text: value }), ], }); grid.appendChild(item); } frag.appendChild(grid); frag.appendChild( Dom.div({ children: [ Dom.span({ styles: { fontSize: "14px", fontWeight: "bold", marginBottom: "5px" }, text: "Description", }), Dom.p({ styles: { color: "#333", fontSize: "14px", lineHeight: "1.4", marginTop: "5px" }, text: house.description ?? "No description available.", }), ], styles: { marginBottom: "20px" }, }), ); frag.appendChild( Dom.div({ children: [ Dom.span({ styles: { fontSize: "14px", fontWeight: "bold", marginBottom: "5px", marginRight: "10px", }, text: "Official Listing", }), Dom.a({ attributes: { rel: "noopener noreferrer", target: "_blank", }, styles: { color: "#0066cc", fontSize: "14px", wordBreak: "break-all" }, text: "Oikotie", url: house.url, }), ], 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(); } } remove() { clearTimeout(this.#timer); this.#dialog.close(); this.#dialog.remove(); this.#abortController.abort(); this.#onHide(); } }