diff options
Diffstat (limited to 'app/components.js')
| -rw-r--r-- | app/components.js | 1891 |
1 files changed, 1039 insertions, 852 deletions
diff --git a/app/components.js b/app/components.js index 27ad900..641e0b0 100644 --- a/app/components.js +++ b/app/components.js @@ -1,4 +1,4 @@ -import { Dom, DomOptions, ToastType } from "dom"; +import { Dom, ToastType } from "dom"; import { AreaParam, Filters, House, HouseParameter, Weights } from "models"; import { Svg, SvgOptions } from "svg"; @@ -120,17 +120,15 @@ export class Histogram { #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", - }, - }), - ); + 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)); @@ -311,18 +309,16 @@ export class Histogram { }), ); - return Dom.div( - new DomOptions({ - children: [svgElement], - styles: { - background: "white", - border: "1px solid #e0e0e0", - borderRadius: "4px", - margin: "1rem 0", - padding: "0.5rem", - }, - }), - ); + return Dom.div({ + children: [svgElement], + styles: { + background: "white", + border: "1px solid #e0e0e0", + borderRadius: "4px", + margin: "1rem 0", + padding: "0.5rem", + }, + }); } /** @@ -337,127 +333,102 @@ 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 + * @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( - 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, - }, - }), - ); + 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 {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 + * @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(label, options, defaultValue, onChange, domOptions = new DomOptions()) { - const selectId = domOptions.id || `dropdown-${Math.random().toString(36).substr(2, 9)}`; + static dropdown(o) { + const selectId = `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%", - }, + 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, }), ), - ], - classes: domOptions.classes, - id: domOptions.id ? `${domOptions.id}-container` : undefined, - styles: { - display: "flex", - flexDirection: "column", - marginBottom: "1rem", - ...domOptions.styles, - }, - }), - ); + 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", + }, + }); } /** @@ -466,33 +437,27 @@ export class Widgets { * @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", - }, - }), - ); + 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", + }, + }); } /** @@ -513,56 +478,44 @@ export class Widgets { * @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" }, - }), - ); + const output = Dom.span({ + id: `${id}-value`, + styles: { color: "#0066cc", fontSize: "0.85rem", fontWeight: "bold", textAlign: "center" }, + text: initialValue.toFixed(1), + }); - 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%", - }, + return Dom.div({ + children: [ + Dom.label({ + children: [ + Dom.span({ + styles: { fontSize: "0.85rem" }, + text: labelText, }), - ), - ], - styles: { display: "flex", flexDirection: "column", marginBottom: "1rem" }, - }), - ); + 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" }, + }); } /** @@ -578,42 +531,365 @@ export class Widgets { * @returns {HTMLElement} */ static rangeFilter(id, labelText, minValue, maxValue, currentMin, currentMax, step, onChange) { - return Dom.div( - new DomOptions({ - children: [ - Dom.label( - id, - labelText, - new DomOptions({ + 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: { - fontSize: "0.85rem", - fontWeight: "bold", - marginBottom: "0.25rem", + borderRadius: "4px", + cursor: "pointer", + flexShrink: "0", + height: "60px", + objectFit: "cover", + width: "60px", }, }), ), - Dom.range( - minValue, - maxValue, - currentMin, - currentMax, - step, - (min, max) => onChange(min, max), - new DomOptions({ - id, + styles: { + display: "flex", + gap: "4px", + justifyContent: "center", + marginBottom: "8px", + }, + }), + // Basic info + Dom.div({ + children: [ + Dom.span({ styles: { - marginBottom: "1rem", + fontSize: "0.8rem", + fontWeight: "bold", + marginBottom: "2px", }, + text: `${(house.price / 1000).toFixed(0)}k€`, }), - ), - ], - styles: { - display: "flex", - flexDirection: "column", - marginBottom: "1.75rem", - }, - }), - ); + 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; } } @@ -717,266 +993,222 @@ export class LeftSidebar { * @returns {HTMLElement} */ #renderContent() { - return Dom.div( - new DomOptions({ - children: [ - // Histogram section - Dom.div( - new DomOptions({ - children: [ - Dom.heading( - 3, - "Distribution", - new DomOptions({ - styles: { - color: "#333", - fontSize: "1.1rem", - margin: "0 0 0.5rem 0", - }, - }), - ), - this.#histogram.render(), - ], + return Dom.div({ + children: [ + // Histogram section + Dom.div({ + children: [ + Dom.heading({ + level: 3, styles: { - borderBottom: "1px solid #eee", - paddingBottom: "1rem", + 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( - new DomOptions({ - children: [ - Dom.heading( - 3, - "Visualisation parameters", - 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 }, - ], - this.#houseParam, - (value) => { - this.#onColorChange(value); - this.#updateHistogram(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 }, - ], - this.#areaParam, - (value) => this.#onAreaColorChange(value), - new DomOptions({ - id: "area-color-parameter", - }), - ), + // 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: { - borderBottom: "1px solid #eee", - paddingBottom: "1rem", + 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, }), - ), - // Filters section - Dom.div( - new DomOptions({ + // 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.heading( - 3, - "Filters", - new DomOptions({ - styles: { - color: "#333", - fontSize: "1.1rem", - margin: "1rem 0 1rem 0", - }, - }), - ), - - // Price filter - Widgets.range( - "Price Range (€)", - this.#filters.minPrice, - this.#filters.maxPrice, - this.#filters.minPrice, - this.#filters.maxPrice, - (min, max) => { - this.#filters.minPrice = min; - this.#filters.maxPrice = - max === this.#filters.maxPrice ? Number.POSITIVE_INFINITY : max; - this.#onFilterChange(); - }, - 10000, - new DomOptions({ - id: "price-range", - }), - ), - - // Construction year filter - Widgets.range( - "Construction Year", - this.#filters.minYear, - this.#filters.maxYear, - this.#filters.minYear, - this.#filters.maxYear, - (min, max) => { - this.#filters.minYear = min; - this.#filters.maxYear = - max === this.#filters.maxYear ? Number.POSITIVE_INFINITY : max; - this.#onFilterChange(); + Dom.label({ + styles: { + fontSize: "0.85rem", + fontWeight: "bold", + marginBottom: "0.25rem", }, - 1, - new DomOptions({ - id: "year-range", - }), - ), - - // Living area filter - Widgets.range( - "Living Area (m²)", - this.#filters.minArea, - this.#filters.maxArea, - this.#filters.minArea, - this.#filters.maxArea, - (min, max) => { - this.#filters.minArea = min; - this.#filters.maxArea = - max === this.#filters.maxArea ? Number.POSITIVE_INFINITY : max; + 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(); }, - 10, - new DomOptions({ - id: "area-range", - }), - ), - - // Lot size filter - Widgets.range( - "Lot Size (m²)", - this.#filters.minLot, - this.#filters.maxLot, - this.#filters.minLot, - this.#filters.maxLot, - (min, max) => { - this.#filters.minLot = min; - this.#filters.maxLot = - max === this.#filters.maxLot ? Number.POSITIVE_INFINITY : max; - this.#onFilterChange(); - }, - 100, - 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, - ); - this.#filters.districts = selectedOptions; - this.#onFilterChange(); - }, - new DomOptions({ - attributes: { multiple: "true" }, - children: [...this.#renderDistrictOptions()], - 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", - () => { - this.#filters.reset(); - this.#onFilterChange(); + styles: { + border: "1px solid #ddd", + borderRadius: "4px", + minHeight: "120px", + padding: "0.5rem", + width: "100%", }, - 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", + display: "flex", + flexDirection: "column", }, }), - ), - ], - id: "left-sidebar-content", - styles: { - display: this.#collapsed ? "none" : "block", - height: "100%", - overflowY: "auto", - }, - }), - ); + + // 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", + }, + }); } /** @@ -987,7 +1219,12 @@ export class LeftSidebar { const houseDistricts = [ ...new Set(this.#allHouses.map((h) => h.district).filter((d) => d)), ].sort(); - return houseDistricts.map((districtName) => Dom.option(districtName, districtName)); + return houseDistricts.map((districtName) => + Dom.option({ + text: districtName, + value: districtName, + }), + ); } /** @@ -995,47 +1232,43 @@ export class LeftSidebar { * @returns {HTMLElement} */ #render() { - return Dom.div( - new DomOptions({ - children: [ - // Toggle button - Dom.button( - "☰", - () => this.toggle(), - new DomOptions({ - id: "left-sidebar-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", - }, - }), - ), - 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", - }, - }), - ); + 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", + }, + }); } /** @@ -1103,108 +1336,104 @@ export class RightSidebar { * @returns {HTMLElement} */ #renderWeights() { - 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", 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, - ), + 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, - ), + // 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, - ), + // 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, - ), - ], - }), - ); + // 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, + ), + ], + }); } /** @@ -1212,57 +1441,51 @@ export class RightSidebar { * @returns {HTMLElement} */ #render() { - return Dom.div( - new DomOptions({ - children: [ - // Toggle button - Dom.button( - "⚙️", - () => this.toggle(), - new DomOptions({ - id: "right-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: [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", - }, - }), - ); + 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", + }, + }); } /** @@ -1355,23 +1578,21 @@ export class Modal { ); this.#dialog.append( - Dom.button( - "x", - () => this.remove(), - new DomOptions({ - id: "close-modal-btn", - styles: { - background: "none", - border: "none", - color: "#666", - cursor: "pointer", - fontSize: "24px", - position: "absolute", - right: "10px", - top: "10px", - }, - }), - ), + 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), ); @@ -1402,67 +1623,57 @@ export class Modal { * @param {House} house */ static imageSection(house) { - return Dom.div( - new DomOptions({ - children: [ - Dom.span( - "Images", - new DomOptions({ - styles: { - fontSize: "14px", - fontWeight: "bold", - marginBottom: "10px", + 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", }, - }), - ), - 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( + children: [ + Dom.img({ + attributes: { + alt: "House image", + loading: "lazy", + }, 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: { + borderRadius: "4px", + cursor: "pointer", + flexShrink: "0", + height: "100px", + transition: "opacity 0.2s ease", + }, + }), + ], styles: { - display: "flex", - gap: "10px", - overflowX: "auto", - paddingBottom: "5px", + display: "block", + textDecoration: "none", }, - }), - ), - ], - styles: { marginBottom: "20px" }, - }), - ); + url: src, + }); + }), + styles: { + display: "flex", + gap: "10px", + overflowX: "auto", + paddingBottom: "5px", + }, + }), + ], + styles: { marginBottom: "20px" }, + }); } /** @@ -1473,51 +1684,43 @@ export class Modal { 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({ + 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: { - display: "grid", - gap: "15px", - gridTemplateColumns: "repeat(2,1fr)", + 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 }, @@ -1529,79 +1732,63 @@ export class Modal { { 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" } })), - ], - }), - ); + 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( - 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" }, - }), - ), + 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( - 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" }, - }), - ), + 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) { |
