diff options
| -rw-r--r-- | README.adoc | 10 | ||||
| -rw-r--r-- | app/components.js | 1891 | ||||
| -rw-r--r-- | app/dom.js | 558 | ||||
| -rw-r--r-- | app/index.html | 5 | ||||
| -rw-r--r-- | app/main.js | 454 | ||||
| -rw-r--r-- | app/models.js | 4 |
6 files changed, 1559 insertions, 1363 deletions
diff --git a/README.adoc b/README.adoc index 0101aec..197e5b1 100644 --- a/README.adoc +++ b/README.adoc @@ -63,10 +63,12 @@ go run main.go == Next steps - Implement additional map features: "koulut", "päiväkodit" -- Visual programming? Value function description with Javascript? -- When scoring weights are manipilated create a botton bar where are top x houses -- Make it possible to get left menu back -- Notifications to user on new houses +- User journey matching for features and fixes accordingly +- UTM projection for geometry +- WebGL? +- Better colors for map +- Lots of refactoring across everything. Code can be reduced by atleast 40% with smarter use +- Make touch gestures work better == Analysis Data processing 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) { @@ -1,27 +1,3 @@ -export class DomOptions { - attributes; - children; - classes; - id; - styles; - - /** - * @param {Object} [options] - * @param {Partial<CSSStyleDeclaration>} [options.styles] - * @param {string|null} [options.id] - * @param {string[]} [options.classes] - * @param {HTMLElement[]} [options.children] - * @param {Record<string, string>} [options.attributes] - */ - constructor({ id = "", styles = {}, classes = [], children = [], attributes = {} } = {}) { - this.attributes = attributes; - this.children = children; - this.classes = classes; - this.id = id; - this.styles = styles; - } -} - /** * Toast notification types * @enum {string} @@ -35,351 +11,432 @@ export const ToastType = { export class Dom { /** * Create a `<div>` - * @param {DomOptions} options + * @param {Object} [o] + * @param {Partial<CSSStyleDeclaration>} [o.styles] + * @param {string} [o.id] + * @param {string[]} [o.classes] + * @param {HTMLElement[]} [o.children] + * @param {Record<string, string>} [o.attributes] * @returns {HTMLDivElement} */ - static div(options = new DomOptions()) { + static div(o = {}) { const div = document.createElement("div"); - Object.assign(div.style, options.styles); - if (options.id) div.id = options.id; - for (const cls of options.classes) div.classList.add(cls); - for (const [k, v] of Object.entries(options.attributes)) div.setAttribute(k, v); - if (options.children) div.append(...options.children); + if (o.styles) Object.assign(div.style, o.styles); + if (o.id) div.id = o.id; + if (o.classes) for (const cls of o.classes) div.classList.add(cls); + if (o.attributes) for (const [k, v] of Object.entries(o.attributes)) div.setAttribute(k, v); + if (o.children) div.append(...o.children); return div; } /** * Create a `<span>` - * @param {string} text - * @param {DomOptions} options + * @param {Object} o + * @param {string} o.text + * @param {Partial<CSSStyleDeclaration>} [o.styles] + * @param {string} [o.id] + * @param {string[]} [o.classes] + * @param {HTMLElement[]} [o.children] + * @param {Record<string, string>} [o.attributes] * @returns {HTMLSpanElement} */ - static span(text, options = new DomOptions()) { + static span(o) { const span = document.createElement("span"); - Object.assign(span.style, options.styles); - if (options.id) span.id = options.id; - for (const cls of options.classes) span.classList.add(cls); - span.textContent = text; - for (const [k, v] of Object.entries(options.attributes)) span.setAttribute(k, v); - if (options.children) span.append(...options.children); + if (o.styles) Object.assign(span.style, o.styles); + if (o.id) span.id = o.id; + if (o.classes) for (const cls of o.classes) span.classList.add(cls); + span.textContent = o.text; + if (o.attributes) for (const [k, v] of Object.entries(o.attributes)) span.setAttribute(k, v); + if (o.children) span.append(...o.children); return span; } /** * Create a `<button>` - * @param { string} text - * @param { (e: Event) => void } onClick - * @param {DomOptions} o + * @param {Object} o + * @param {string} [o.text] + * @param {(e: Event) => void} [o.onClick] + * @param {Partial<CSSStyleDeclaration>} [o.styles] + * @param {string} [o.id] + * @param {string[]} [o.classes] + * @param {HTMLElement[]} [o.children] + * @param {Record<string, string>} [o.attributes] * @returns {HTMLButtonElement} */ - static button(text, onClick, o = new DomOptions()) { + static button(o = {}) { const button = document.createElement("button"); - Object.assign(button.style, o.styles); + if (o.styles) Object.assign(button.style, o.styles); if (o.id) button.id = o.id; - for (const cls of o.classes) button.classList.add(cls); - if (text) button.textContent = text; - for (const [k, v] of Object.entries(o.attributes)) button.setAttribute(k, v); + if (o.classes) for (const cls of o.classes) button.classList.add(cls); + if (o.text) button.textContent = o.text; + if (o.attributes) for (const [k, v] of Object.entries(o.attributes)) button.setAttribute(k, v); if (o.children) button.append(...o.children); - if (onClick) button.addEventListener("pointerdown", onClick); + if (o.onClick) button.addEventListener("pointerdown", o.onClick); return button; } /** * Create an `<input>` - * @param { string} type - * @param { (e: Event) => void } onChange - * @param { string|number} value - * @param { string} placeholder - * @param {DomOptions} [o] + * @param {Object} o + * @param {string} o.type + * @param {(e: Event) => void} [o.onChange] + * @param {(e: Event) => void} [o.onInput] + * @param {string|number} [o.value] + * @param {string} [o.placeholder] + * @param {string} [o.min] + * @param {string} [o.max] + * @param {string} [o.step] + * @param {Partial<CSSStyleDeclaration>} [o.styles] + * @param {string} [o.id] + * @param {string[]} [o.classes] + * @param {Record<string, string>} [o.attributes] * @returns {HTMLInputElement} */ - static input(type, onChange, value = "", placeholder = "", o = new DomOptions()) { + static input(o) { const input = document.createElement("input"); - Object.assign(input.style, o.styles); + if (o.styles) Object.assign(input.style, o.styles); if (o.id) input.id = o.id; - for (const cls of o.classes) input.classList.add(cls); - for (const [k, v] of Object.entries(o.attributes)) input.setAttribute(k, v); - - input.type = type; - input.placeholder = placeholder; - input.value = value.toString(); - if (onChange) { - input.addEventListener("change", onChange); + if (o.classes) for (const cls of o.classes) input.classList.add(cls); + if (o.attributes) for (const [k, v] of Object.entries(o.attributes)) input.setAttribute(k, v); + + input.type = o.type; + if (o.placeholder) input.placeholder = o.placeholder; + if (o.value !== undefined) input.value = o.value.toString(); + if (o.min !== undefined) input.min = o.min; + if (o.max !== undefined) input.max = o.max; + if (o.step !== undefined) input.step = o.step; + + if (o.onChange) { + input.addEventListener("change", o.onChange); + } + if (o.onInput) { + input.addEventListener("input", o.onInput); } return input; } /** * Create a `<strong>` - * @param {string} text + * @param {Object} o + * @param {string} o.text + * @returns {HTMLElement} */ - static strong(text) { + static strong(o) { const strong = document.createElement("strong"); - strong.textContent = text; + strong.textContent = o.text; return strong; } /** * Create a `<a>` - * @param {string} url - * @param {DomOptions} o - * @param {string|undefined} text + * @param {Object} o + * @param {string} o.url + * @param {string} [o.text] + * @param {Partial<CSSStyleDeclaration>} [o.styles] + * @param {string} [o.id] + * @param {string[]} [o.classes] + * @param {HTMLElement[]} [o.children] + * @param {Record<string, string>} [o.attributes] + * @returns {HTMLAnchorElement} */ - static a(url, o, text = undefined) { + static a(o) { const link = document.createElement("a"); - if (text) link.text = text; - link.href = url; - Object.assign(link.style, o.styles); + if (o.text) link.text = o.text; + link.href = o.url; + if (o.styles) Object.assign(link.style, o.styles); if (o.id) link.id = o.id; - for (const cls of o.classes) link.classList.add(cls); - for (const [k, v] of Object.entries(o.attributes)) link.setAttribute(k, v); + if (o.classes) for (const cls of o.classes) link.classList.add(cls); + if (o.attributes) for (const [k, v] of Object.entries(o.attributes)) link.setAttribute(k, v); if (o.children) link.append(...o.children); return link; } /** * Create a `<label>` - * @param {string} to - * @param {string} text - * @param {DomOptions} o + * @param {Object} o + * @param {string} o.to + * @param {string} [o.text] + * @param {Partial<CSSStyleDeclaration>} [o.styles] + * @param {string} [o.id] + * @param {string[]} [o.classes] + * @param {HTMLElement[]} [o.children] + * @param {Record<string, string>} [o.attributes] * @returns {HTMLLabelElement} */ - static label(to, text, o = new DomOptions()) { + static label(o) { const label = document.createElement("label"); - Object.assign(label.style, o.styles); + if (o.styles) Object.assign(label.style, o.styles); if (o.id) label.id = o.id; - for (const cls of o.classes) label.classList.add(cls); - for (const [k, v] of Object.entries(o.attributes)) label.setAttribute(k, v); + if (o.classes) for (const cls of o.classes) label.classList.add(cls); + if (o.attributes) for (const [k, v] of Object.entries(o.attributes)) label.setAttribute(k, v); if (o.children) label.append(...o.children); - label.textContent = text; - label.htmlFor = to; + if (o.text) label.textContent = o.text; + label.htmlFor = o.to; return label; } /** * Create a heading `<h1>`–`<h6>` - * @param {1|2|3|4|5|6} level - * @param {string} text - * @param {DomOptions} o + * @param {Object} o + * @param {1|2|3|4|5|6} o.level + * @param {string} [o.text] + * @param {Partial<CSSStyleDeclaration>} [o.styles] + * @param {string} [o.id] + * @param {string[]} [o.classes] + * @param {HTMLElement[]} [o.children] + * @param {Record<string, string>} [o.attributes] * @returns {HTMLHeadingElement} */ - static heading(level, text, o = new DomOptions()) { - const heading = document.createElement(`h${level}`); - Object.assign(heading.style, o.styles); + static heading(o) { + const heading = document.createElement(`h${o.level}`); + if (o.styles) Object.assign(heading.style, o.styles); if (o.id) heading.id = o.id; - for (const cls of o.classes) heading.classList.add(cls); - if (text) heading.textContent = text; - for (const [k, v] of Object.entries(o.attributes)) heading.setAttribute(k, v); + if (o.classes) for (const cls of o.classes) heading.classList.add(cls); + if (o.text) heading.textContent = o.text; + if (o.attributes) for (const [k, v] of Object.entries(o.attributes)) heading.setAttribute(k, v); if (o.children) heading.append(...o.children); return heading; } /** * Create an `<img>` - * @param {string} src - * @param {DomOptions} o + * @param {Object} o + * @param {string} o.src + * @param {Partial<CSSStyleDeclaration>} [o.styles] + * @param {string} [o.id] + * @param {string[]} [o.classes] + * @param {Record<string, string>} [o.attributes] * @returns {HTMLImageElement} */ - static img(src, o = new DomOptions()) { + static img(o) { const img = document.createElement("img"); - Object.assign(img.style, o.styles); + if (o.styles) Object.assign(img.style, o.styles); if (o.id) img.id = o.id; - for (const cls of o.classes) img.classList.add(cls); - for (const [k, v] of Object.entries(o.attributes)) img.setAttribute(k, v); - img.src = src; + if (o.classes) for (const cls of o.classes) img.classList.add(cls); + if (o.attributes) for (const [k, v] of Object.entries(o.attributes)) img.setAttribute(k, v); + img.src = o.src; return img; } /** * Create a `<select>` - * @param {string|undefined} selected - * @param {DomOptions} o - * @param { (e: Event) => void } onChange + * @param {Object} o + * @param {string} [o.selected] + * @param {(e: Event) => void} [o.onChange] + * @param {Partial<CSSStyleDeclaration>} [o.styles] + * @param {string} [o.id] + * @param {string[]} [o.classes] + * @param {HTMLElement[]} [o.children] + * @param {Record<string, string>} [o.attributes] * @returns {HTMLSelectElement} */ - static select(selected = undefined, onChange, o = new DomOptions()) { + static select(o = {}) { const select = document.createElement("select"); - Object.assign(select.style, o.styles); - if (selected !== undefined) select.value = selected; + if (o.styles) Object.assign(select.style, o.styles); + if (o.selected !== undefined) select.value = o.selected; if (o.id) select.id = o.id; - for (const cls of o.classes) select.classList.add(cls); - for (const [k, v] of Object.entries(o.attributes)) select.setAttribute(k, v); + if (o.classes) for (const cls of o.classes) select.classList.add(cls); + if (o.attributes) for (const [k, v] of Object.entries(o.attributes)) select.setAttribute(k, v); if (o.children) select.append(...o.children); - if (onChange) select.addEventListener("change", onChange); + if (o.onChange) select.addEventListener("change", o.onChange); return select; } /** * Create an `<option>` - * @param {string} value - * @param {string} text - * @param {boolean} [selected=false] + * @param {Object} o + * @param {string} o.value + * @param {string} o.text + * @param {boolean} [o.selected] * @returns {HTMLOptionElement} */ - static option(value, text, selected = false) { + static option(o) { const opt = document.createElement("option"); - opt.value = value; - opt.textContent = text; - opt.selected = selected; + opt.value = o.value; + opt.textContent = o.text; + opt.selected = o.selected || false; return opt; } /** * Create a `<p>` - * @param {string} text - * @param {DomOptions} o + * @param {Object} o + * @param {string} [o.text] + * @param {Partial<CSSStyleDeclaration>} [o.styles] + * @param {string} [o.id] + * @param {string[]} [o.classes] + * @param {HTMLElement[]} [o.children] + * @param {Record<string, string>} [o.attributes] * @returns {HTMLParagraphElement} */ - static p(text, o = new DomOptions()) { + static p(o = {}) { const p = document.createElement("p"); - Object.assign(p.style, o.styles); + if (o.styles) Object.assign(p.style, o.styles); if (o.id) p.id = o.id; - for (const cls of o.classes) p.classList.add(cls); - if (text) p.textContent = text; - for (const [k, v] of Object.entries(o.attributes)) p.setAttribute(k, v); + if (o.classes) for (const cls of o.classes) p.classList.add(cls); + if (o.text) p.textContent = o.text; + if (o.attributes) for (const [k, v] of Object.entries(o.attributes)) p.setAttribute(k, v); if (o.children) p.append(...o.children); return p; } /** * Create a dual range input (min and max) - * @param {number} min - Minimum possible value - * @param {number} max - Maximum possible value - * @param {number} currentMin - Current minimum value - * @param {number} currentMax - Current maximum value - * @param {number} step - Step size - * @param {(min: number, max: number) => void} onChange - Callback when range changes - * @param {DomOptions} options - DOM options + * @param {Object} o + * @param {number} o.min - Minimum possible value + * @param {number} o.max - Maximum possible value + * @param {number} o.currentMin - Current minimum value + * @param {number} o.currentMax - Current maximum value + * @param {number} o.step - Step size + * @param {(min: number, max: number) => void} o.onChange - Callback when range changes + * @param {Partial<CSSStyleDeclaration>} [o.styles] + * @param {string} [o.id] + * @param {string[]} [o.classes] + * @param {HTMLElement[]} [o.children] + * @param {Record<string, string>} [o.attributes] * @returns {HTMLDivElement} */ - static range(min, max, currentMin, currentMax, step, onChange, options = new DomOptions()) { + static range(o) { const container = document.createElement("div"); - Object.assign(container.style, options.styles); - if (options.id) container.id = options.id; - for (const cls of options.classes) container.classList.add(cls); - for (const [k, v] of Object.entries(options.attributes)) container.setAttribute(k, v); + if (o.styles) Object.assign(container.style, o.styles); + if (o.id) container.id = o.id; + if (o.classes) for (const cls of o.classes) container.classList.add(cls); + if (o.attributes) + for (const [k, v] of Object.entries(o.attributes)) container.setAttribute(k, v); // Ensure current values are within bounds - const safeCurrentMin = Math.max(min, Math.min(currentMin, max)); - const safeCurrentMax = Math.max(min, Math.min(currentMax, max)); + const safeCurrentMin = Math.max(o.min, Math.min(o.currentMin, o.max)); + const safeCurrentMax = Math.max(o.min, Math.min(o.currentMax, o.max)); // Create track container - const trackContainer = Dom.div( - new DomOptions({ - styles: { - alignItems: "center", - display: "flex", - height: "20px", - margin: "10px 0", - position: "relative", - }, - }), - ); + const trackContainer = Dom.div({ + styles: { + alignItems: "center", + display: "flex", + height: "20px", + margin: "10px 0", + position: "relative", + }, + }); // Create track - const track = Dom.div( - new DomOptions({ - styles: { - background: "#e0e0e0", - borderRadius: "4px", - height: "6px", - position: "relative", - width: "100%", - }, - }), - ); + const track = Dom.div({ + styles: { + background: "#e0e0e0", + borderRadius: "4px", + height: "6px", + position: "relative", + width: "100%", + }, + }); // Create active range - const activeRange = Dom.div( - new DomOptions({ - styles: { - background: "#4caf50", - borderRadius: "4px", - height: "100%", - left: "0%", - position: "absolute", - width: "100%", - }, - }), - ); - - // Create min slider - const minSlider = document.createElement("input"); - minSlider.type = "range"; - minSlider.min = min.toString(); - minSlider.max = max.toString(); - minSlider.step = step.toString(); - minSlider.value = safeCurrentMin.toString(); - Object.assign(minSlider.style, { - appearance: "none", - background: "transparent", - height: "100%", - pointerEvents: "none", - position: "absolute", - width: "100%", - zIndex: "2", + const activeRange = Dom.div({ + styles: { + background: "#4caf50", + borderRadius: "4px", + height: "100%", + left: "0%", + position: "absolute", + width: "100%", + }, + }); + + // Create min slider using Dom.input + const minSlider = Dom.input({ + max: o.max.toString(), + min: o.min.toString(), + onInput: () => { + const minVal = parseInt(minSlider.value, 10); + const maxVal = parseInt(maxSlider.value, 10); + + if (minVal > maxVal) { + minSlider.value = maxVal.toString(); + } + + updateActiveRange(); + o.onChange(parseInt(minSlider.value, 10), parseInt(maxSlider.value, 10)); + }, + step: o.step.toString(), + styles: { + appearance: "none", + background: "transparent", + height: "100%", + pointerEvents: "none", + position: "absolute", + width: "100%", + zIndex: "2", + }, + type: "range", + value: safeCurrentMin, }); minSlider.style.pointerEvents = "auto"; - // Create max slider - const maxSlider = document.createElement("input"); - maxSlider.type = "range"; - maxSlider.min = min.toString(); - maxSlider.max = max.toString(); - maxSlider.step = step.toString(); - maxSlider.value = safeCurrentMax.toString(); - Object.assign(maxSlider.style, { - appearance: "none", - background: "transparent", - height: "100%", - pointerEvents: "none", - position: "absolute", - width: "100%", - zIndex: "2", + // Create max slider using Dom.input + const maxSlider = Dom.input({ + max: o.max.toString(), + min: o.min.toString(), + onInput: () => { + const minVal = parseInt(minSlider.value, 10); + const maxVal = parseInt(maxSlider.value, 10); + + if (maxVal < minVal) { + maxSlider.value = minVal.toString(); + } + + updateActiveRange(); + o.onChange(parseInt(minSlider.value, 10), parseInt(maxSlider.value, 10)); + }, + step: o.step.toString(), + styles: { + appearance: "none", + background: "transparent", + height: "100%", + pointerEvents: "none", + position: "absolute", + width: "100%", + zIndex: "2", + }, + type: "range", + value: safeCurrentMax, }); maxSlider.style.pointerEvents = "auto"; // Value displays - const minValueDisplay = Dom.span( - safeCurrentMin.toString(), - new DomOptions({ - styles: { - color: "#0066cc", - fontSize: "0.85rem", - fontWeight: "bold", - }, - }), - ); - - const maxValueDisplay = Dom.span( - safeCurrentMax.toString(), - new DomOptions({ - styles: { - color: "#0066cc", - fontSize: "0.85rem", - fontWeight: "bold", - }, - }), - ); - - const valueDisplay = Dom.div( - new DomOptions({ - children: [minValueDisplay, maxValueDisplay], - styles: { - display: "flex", - justifyContent: "space-between", - marginBottom: "5px", - }, - }), - ); + const minValueDisplay = Dom.span({ + styles: { + color: "#0066cc", + fontSize: "0.85rem", + fontWeight: "bold", + }, + text: safeCurrentMin.toString(), + }); + + const maxValueDisplay = Dom.span({ + styles: { + color: "#0066cc", + fontSize: "0.85rem", + fontWeight: "bold", + }, + text: safeCurrentMax.toString(), + }); + + const valueDisplay = Dom.div({ + children: [minValueDisplay, maxValueDisplay], + styles: { + display: "flex", + justifyContent: "space-between", + marginBottom: "5px", + }, + }); // Update active range position const updateActiveRange = () => { const minVal = parseInt(minSlider.value, 10); const maxVal = parseInt(maxSlider.value, 10); - const minPercent = ((minVal - min) / (max - min)) * 100; - const maxPercent = ((maxVal - min) / (max - min)) * 100; + const minPercent = ((minVal - o.min) / (o.max - o.min)) * 100; + const maxPercent = ((maxVal - o.min) / (o.max - o.min)) * 100; activeRange.style.left = `${minPercent}%`; activeRange.style.width = `${maxPercent - minPercent}%`; @@ -388,31 +445,6 @@ export class Dom { maxValueDisplay.textContent = maxVal.toString(); }; - // Event listeners - minSlider.addEventListener("input", () => { - const minVal = parseInt(minSlider.value, 10); - const maxVal = parseInt(maxSlider.value, 10); - - if (minVal > maxVal) { - minSlider.value = maxVal.toString(); - } - - updateActiveRange(); - onChange(parseInt(minSlider.value, 10), parseInt(maxSlider.value, 10)); - }); - - maxSlider.addEventListener("input", () => { - const minVal = parseInt(minSlider.value, 10); - const maxVal = parseInt(maxSlider.value, 10); - - if (maxVal < minVal) { - maxSlider.value = minVal.toString(); - } - - updateActiveRange(); - onChange(parseInt(minSlider.value, 10), parseInt(maxSlider.value, 10)); - }); - // Initial update updateActiveRange(); @@ -423,7 +455,7 @@ export class Dom { // Assemble container container.append(valueDisplay, trackContainer); - if (options.children) container.append(...options.children); + if (o.children) container.append(...o.children); return container; } diff --git a/app/index.html b/app/index.html index aca331c..37a72dc 100644 --- a/app/index.html +++ b/app/index.html @@ -2,7 +2,10 @@ <html lang="en"> <head> <meta charset="UTF-8"> - <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <meta + name="viewport" + content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" + /> <link rel="icon" href="favicon.svg" type="image/svg+xml"> <title>Search Engine - Helsinki</title> diff --git a/app/main.js b/app/main.js index ef51345..4ccebe0 100644 --- a/app/main.js +++ b/app/main.js @@ -1,7 +1,7 @@ -// main.js - Updated with Sidebar class +// main.js - Updated with Sidebar class and new Dom interface -import { LeftSidebar, Modal, RightSidebar } from "components"; -import { Dom, DomOptions } from "dom"; +import { BottomBar, LeftSidebar, Modal, RightSidebar } from "components"; +import { Dom } from "dom"; import { MapEl } from "map"; import { AreaParam, @@ -20,67 +20,57 @@ export class Init { #collection = null; constructor() { - this.#loadingElement = Dom.div( - new DomOptions({ - children: [ - Dom.div( - new DomOptions({ - children: [ - Dom.span( - "🏠", - new DomOptions({ - styles: { - fontSize: "3rem", - marginBottom: "1rem", - }, - }), - ), - Dom.span( - "Loading Housing Application...", - new DomOptions({ - styles: { - color: "#333", - fontSize: "1.2rem", - fontWeight: "500", - }, - }), - ), - Dom.span( - "Please wait while we load and process the data", - new DomOptions({ - styles: { - color: "#666", - fontSize: "0.9rem", - marginTop: "0.5rem", - }, - }), - ), - ], + this.#loadingElement = Dom.div({ + children: [ + Dom.div({ + children: [ + Dom.span({ styles: { - alignItems: "center", - display: "flex", - flexDirection: "column", - textAlign: "center", + fontSize: "3rem", + marginBottom: "1rem", }, + text: "🏠", }), - ), - ], - styles: { - alignItems: "center", - background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)", - color: "white", - display: "flex", - fontFamily: "Roboto Mono, monospace", - height: "100%", - justifyContent: "center", - left: "0", - position: "fixed", - top: "0", - width: "100%", - zIndex: "9999", - }, - }), - ); + Dom.span({ + styles: { + color: "#333", + fontSize: "1.2rem", + fontWeight: "500", + }, + text: "Loading Housing Application...", + }), + Dom.span({ + styles: { + color: "#666", + fontSize: "0.9rem", + marginTop: "0.5rem", + }, + text: "Please wait while we load and process the data", + }), + ], + styles: { + alignItems: "center", + display: "flex", + flexDirection: "column", + textAlign: "center", + }, + }), + ], + styles: { + alignItems: "center", + background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)", + color: "white", + display: "flex", + fontFamily: "Roboto Mono, monospace", + height: "100%", + justifyContent: "center", + left: "0", + position: "fixed", + top: "0", + width: "100%", + zIndex: "9999", + }, + }); document.body.appendChild(this.#loadingElement); Object.assign(document.body.style, { @@ -120,86 +110,74 @@ export class Init { * @param {string} message */ static getError(message) { - return Dom.div( - new DomOptions({ - children: [ - Dom.div( - new DomOptions({ - children: [ - Dom.span( - "❌", - new DomOptions({ - styles: { - fontSize: "3rem", - marginBottom: "1rem", - }, - }), - ), - Dom.span( - "Application Error", - new DomOptions({ - styles: { - color: "#c53030", - fontSize: "1.5rem", - fontWeight: "bold", - marginBottom: "1rem", - }, - }), - ), - Dom.span( - message, - new DomOptions({ - styles: { - color: "#744210", - fontSize: "1rem", - lineHeight: "1.5", - marginBottom: "2rem", - textAlign: "center", - }, - }), - ), - Dom.button( - "Refresh Page", - () => location.reload(), - new DomOptions({ - styles: { - background: "#c53030", - border: "none", - borderRadius: "6px", - color: "white", - cursor: "pointer", - fontSize: "1rem", - padding: "0.75rem 1.5rem", - transition: "background-color 0.2s", - }, - }), - ), - ], + return Dom.div({ + children: [ + Dom.div({ + children: [ + Dom.span({ + styles: { + fontSize: "3rem", + marginBottom: "1rem", + }, + text: "❌", + }), + Dom.span({ styles: { - alignItems: "center", - display: "flex", - flexDirection: "column", - maxWidth: "400px", + color: "#c53030", + fontSize: "1.5rem", + fontWeight: "bold", + marginBottom: "1rem", + }, + text: "Application Error", + }), + Dom.span({ + styles: { + color: "#744210", + fontSize: "1rem", + lineHeight: "1.5", + marginBottom: "2rem", textAlign: "center", }, + text: message, }), - ), - ], - styles: { - alignItems: "center", - background: "#fed7d7", - display: "flex", - fontFamily: "Roboto Mono, monospace", - height: "100%", - justifyContent: "center", - left: "0", - position: "fixed", - top: "0", - width: "100%", - zIndex: "9999", - }, - }), - ); + Dom.button({ + onClick: () => location.reload(), + styles: { + background: "#c53030", + border: "none", + borderRadius: "6px", + color: "white", + cursor: "pointer", + fontSize: "1rem", + padding: "0.75rem 1.5rem", + transition: "background-color 0.2s", + }, + text: "Refresh Page", + }), + ], + styles: { + alignItems: "center", + display: "flex", + flexDirection: "column", + maxWidth: "400px", + textAlign: "center", + }, + }), + ], + styles: { + alignItems: "center", + background: "#fed7d7", + display: "flex", + fontFamily: "Roboto Mono, monospace", + height: "100%", + justifyContent: "center", + left: "0", + position: "fixed", + top: "0", + width: "100%", + zIndex: "9999", + }, + }); } /** @@ -233,6 +211,8 @@ export class App { #houseParameter = HouseParameter.price; /** @type {AreaParam} */ #areaParameter = AreaParam.unemploymentRate; + /** @type {BottomBar} */ + #bottomBar; /** * @param {Collection} collection @@ -242,6 +222,11 @@ export class App { this.#collection = collection; this.#filters = filters; + this.#bottomBar = new BottomBar({ + houses: this.#collection.houses.sort((a, b) => b.scores.current - a.scores.current), + onHouseClick: (houseId) => this.#showHouseModal(houseId, true), + }); + Object.assign(document.body.style, { display: "flex", flexDirection: "column", @@ -251,51 +236,48 @@ export class App { }); // Create header with global toggle buttons - const header = Dom.div( - new DomOptions({ - children: [ - // Left sidebar toggle button - Dom.button( - "☰ Filters", - () => this.#leftSidebar.toggle(), - new DomOptions({ - styles: { - background: "#fff", - border: "1px solid #ddd", - borderRadius: "4px", - cursor: "pointer", - fontSize: "1rem", - margin: "0.5rem", - padding: "0.5rem 1rem", - }, - }), - ), - // Right sidebar toggle button - Dom.button( - "⚙️ Weights", - () => this.#rightSidebar.toggle(), - new DomOptions({ - styles: { - background: "#fff", - border: "1px solid #ddd", - borderRadius: "4px", - cursor: "pointer", - fontSize: "1rem", - margin: "0.5rem", - padding: "0.5rem 1rem", - }, - }), - ), - ], - styles: { - background: "#f5f5f5", - borderBottom: "1px solid #ddd", - display: "flex", - justifyContent: "space-between", - padding: "0", - }, - }), - ); + const header = Dom.div({ + children: [ + // Left sidebar toggle button + Dom.button({ + onClick: () => this.#leftSidebar.toggle(), + styles: { + background: "#fff", + border: "1px solid #ddd", + borderRadius: "4px", + cursor: "pointer", + fontSize: "1rem", + margin: "0.5rem", + padding: "0.5rem 1rem", + }, + text: "☰ Filters", + }), + // Right sidebar toggle button + Dom.button({ + onClick: () => { + this.#bottomBar.show(); + this.#rightSidebar.toggle(); + }, + styles: { + background: "#fff", + border: "1px solid #ddd", + borderRadius: "4px", + cursor: "pointer", + fontSize: "1rem", + margin: "0.5rem", + padding: "0.5rem 1rem", + }, + text: "⚙️ Weights", + }), + ], + styles: { + background: "#f5f5f5", + borderBottom: "1px solid #ddd", + display: "flex", + justifyContent: "space-between", + padding: "0", + }, + }); this.#leftSidebar = new LeftSidebar({ allHouses: this.#collection.houses, @@ -339,55 +321,52 @@ export class App { areaParameter: this.#areaParameter, collection: this.#collection, houseParameter: this.#houseParameter, + /** @param {string} houseId @param {boolean} persistent */ onHouseClick: (houseId, persistent) => this.#showHouseModal(houseId, persistent), + /** @param {string} houseId @param {boolean} hide */ onHouseHover: (houseId, hide) => { - hide ? this.#modal?.hide() : this.#showHouseModal(houseId, false); + hide ? this.#modal?.remove() : this.#showHouseModal(houseId, false); }, }); this.#stats = App.#createStats(this.#collection.houses, this.#filters); document.body.appendChild( - Dom.div( - new DomOptions({ - children: [ - header, - Dom.div( - new DomOptions({ - children: [ - this.#leftSidebar.render(), - Dom.div( - new DomOptions({ - children: [this.#map.svg, this.#stats], - id: "map-container", - styles: { - display: "flex", - flex: "1", - flexDirection: "column", - minWidth: "0", - }, - }), - ), - this.#rightSidebar.render(), - ], - id: "main-content", + Dom.div({ + children: [ + header, + Dom.div({ + children: [ + this.#leftSidebar.render(), + Dom.div({ + children: [this.#map.svg, this.#stats], + id: "map-container", styles: { display: "flex", flex: "1", - height: "calc(100vh - 60px)", - overflow: "hidden", + flexDirection: "column", + minWidth: "0", }, }), - ), - ], - id: "main", - styles: { - display: "flex", - flexDirection: "column", - height: "100vh", - }, - }), - ), + this.#rightSidebar.render(), + this.#bottomBar.render(), + ], + id: "main-content", + styles: { + display: "flex", + flex: "1", + height: "calc(100vh - 60px)", + overflow: "hidden", + }, + }), + ], + id: "main", + styles: { + display: "flex", + flexDirection: "column", + height: "100vh", + }, + }), ); } @@ -430,11 +409,6 @@ export class App { } /** - * Apply filters and recalculate scores - */ - #applyFiltersAndScoring() {} - - /** * Create statistics display * @param {House[]} houses * @param {Filters} filters @@ -446,24 +420,22 @@ export class App { ? Math.round(filtered.reduce((s, h) => s + h.scores.current, 0) / filtered.length) : 0; - return Dom.div( - new DomOptions({ - children: [ - Dom.strong(`${filtered.length.toString()}/${houses.length}`), - Dom.span(" houses shown • Average score: "), - Dom.strong(averageScore.toString()), - Dom.span(" • Use weights sliders to adjust scoring"), - ], - id: "stats", - styles: { - background: "#fff", - borderTop: "1px solid #ddd", - flexShrink: "0", - fontSize: "0.95rem", - padding: "0.75rem 1rem", - }, - }), - ); + return Dom.div({ + children: [ + Dom.strong({ text: `${filtered.length.toString()}/${houses.length}` }), + Dom.span({ text: " houses shown • Average score: " }), + Dom.strong({ text: averageScore.toString() }), + Dom.span({ text: " • Use weights sliders to adjust scoring" }), + ], + id: "stats", + styles: { + background: "#fff", + borderTop: "1px solid #ddd", + flexShrink: "0", + fontSize: "0.95rem", + padding: "0.75rem 1rem", + }, + }); } } diff --git a/app/models.js b/app/models.js index 95f18fd..fa9f945 100644 --- a/app/models.js +++ b/app/models.js @@ -1123,7 +1123,7 @@ export class ScoringEngine { // 3. Convert to perceived value // ---------------------------- - const PV = Math.exp(logPV); + const Pv = Math.exp(logPV); // ---------------------------- // 4. Compare with listing price @@ -1131,7 +1131,7 @@ export class ScoringEngine { if (!house.price || house.price <= 0) return 0; - const desirability = (PV / house.price) * 100; + const desirability = (Pv / house.price) * 100; return Math.max(0, Math.min(100, desirability)); } |
