import { Dom, DomOptions, ToastType } from "dom"; import { AreaParam, District, Filters, House, HouseParameter, Weights } from "models"; export class Widgets { /** * Show toast notification * @param {string} message * @param {ToastType} [type=ToastType.error] */ static toast(message, type = ToastType.error) { return Dom.div( new DomOptions({ children: [Dom.p(message)], classes: ["toast", `toast-${type}`], id: "app-toast", styles: { background: type === ToastType.error ? "#f44336" : type === ToastType.warning ? "#ff9800" : "#4caf50", borderRadius: "4px", boxShadow: "0 4px 12px rgba(0,0,0,0.15)", color: "white", fontSize: "14px", fontWeight: "500", maxWidth: "300px", padding: "12px 20px", position: "fixed", right: "20px", top: "20px", transition: "all .3s ease", zIndex: "10000", }, }), ); } /** * Remove all children * @param {HTMLElement} el */ static clear(el) { while (el.firstChild) el.removeChild(el.firstChild); } /** * Create a weight slider * @param {string} id * @param {string} labelText * @param {string} weightKey * @param {number} initialValue * @param {(key: string, value: number) => void} onChange * @returns {HTMLElement} */ static slider(id, labelText, weightKey, initialValue, onChange) { const output = Dom.span( initialValue.toFixed(1), new DomOptions({ id: `${id}-value`, styles: { color: "#0066cc", fontSize: "0.85rem", fontWeight: "bold", textAlign: "center" }, }), ); return Dom.div( new DomOptions({ children: [ Dom.label( id, labelText, new DomOptions({ children: [ Dom.span( labelText, new DomOptions({ styles: { fontSize: "0.85rem" }, }), ), Dom.span(" "), output, ], }), ), Dom.input( "range", (e) => { const target = /** @type {HTMLInputElement} */ (e.target); const val = Number(target.value); output.textContent = val.toFixed(1); onChange(weightKey, val); }, "", "", new DomOptions({ attributes: { max: "1", min: "0", step: "0.1", value: initialValue.toString() }, id, styles: { margin: "0.5rem 0", width: "100%", }, }), ), ], styles: { display: "flex", flexDirection: "column", marginBottom: "1rem" }, }), ); } /** * Create a number filter input * @param {string} id * @param {string} labelText * @param {(value: number | null) => void} onChange * @returns {HTMLElement} */ static numberFilter(id, labelText, onChange) { return Dom.div( new DomOptions({ children: [ Dom.label( id, labelText, new DomOptions({ styles: { fontSize: "0.85rem", fontWeight: "bold", marginBottom: "0.25rem" }, }), ), Dom.input( "number", (e) => { const target = /** @type {HTMLInputElement} */ (e.target); const raw = target.value.trim(); onChange(raw === "" ? null : Number(raw)); }, "any", "", new DomOptions({ id, styles: { border: "1px solid #ddd", borderRadius: "4px", fontSize: "0.9rem", padding: "0.5rem", }, }), ), ], styles: { display: "flex", flexDirection: "column", marginBottom: "1.75rem" }, }), ); } } export class Sidebar { /** @type {HTMLElement} */ #rootElement; /** @type {boolean} */ #collapsed = false; /** @type {Filters} */ #filters; /** @type {Weights} */ #weights; /** @type {() => void} */ #onFilterChange; /** @type {(key: string, value: number) => void} */ #onWeightChange; /** @type {(param: string) => void} */ #onColorChange; /** @type {(param: string) => void} */ #onAreaColorChange; /** * @param {Filters} filters * @param {Weights} weights * @param {() => void} onFilterChange * @param {(key: string, value: number) => void} onWeightChange * @param {(param: string) => void} onColorChange * @param {(param: string) => void} onAreaColorChange */ constructor(filters, weights, onFilterChange, onWeightChange, onColorChange, onAreaColorChange) { this.#filters = filters; this.#weights = weights; this.#onFilterChange = onFilterChange; this.#onWeightChange = onWeightChange; this.#onColorChange = onColorChange; this.#onAreaColorChange = onAreaColorChange; this.#rootElement = this.#render(); } /** * Render sidebar container * @returns {HTMLElement} */ #render() { return Dom.div( new DomOptions({ children: [ // Toggle button Dom.button( "☰", () => this.toggle(), new DomOptions({ id: "sidebar-toggle", styles: { background: "none", border: "none", color: "#333", cursor: "pointer", fontSize: "1.5rem", padding: "0.5rem", position: "absolute", right: "0.5rem", top: "0.5rem", zIndex: "10", }, }), ), // Sidebar content Dom.div( new DomOptions({ children: [ // Map Colors Section Dom.div( new DomOptions({ children: [ Dom.heading( 3, "Map Colors", new DomOptions({ styles: { color: "#333", fontSize: "1.1rem", margin: "0 0 1rem 0", }, }), ), Dom.div( new DomOptions({ children: [ Dom.label( "color-parameter", "Color houses by", new DomOptions({ styles: { fontSize: "0.85rem", fontWeight: "bold", marginBottom: "0.25rem", }, }), ), Dom.select( (e) => { const target = /** @type {HTMLSelectElement} */ (e.target); this.#onColorChange(target.value); }, new DomOptions({ children: [ Dom.option(HouseParameter.price, "Price"), Dom.option(HouseParameter.score, "Score"), Dom.option(HouseParameter.year, "Construction Year"), Dom.option(HouseParameter.area, "Living Area"), ], id: "color-parameter", styles: { border: "1px solid #ddd", borderRadius: "4px", fontSize: "0.9rem", padding: "0.5rem", width: "100%", }, }), ), ], styles: { display: "flex", flexDirection: "column", marginBottom: "1rem", }, }), ), Dom.div( new DomOptions({ children: [ Dom.label( "area-color-parameter", "Color areas by", new DomOptions({ styles: { fontSize: "0.85rem", fontWeight: "bold", marginBottom: "0.25rem", }, }), ), Dom.select( (e) => { const target = /** @type {HTMLSelectElement} */ (e.target); this.#onAreaColorChange(target.value); }, new DomOptions({ children: [ Dom.option(AreaParam.none, "None"), Dom.option(AreaParam.foreignSpeakers, "Foreign speakers"), Dom.option(AreaParam.unemploymentRate, "Unemployment rate"), Dom.option(AreaParam.averageIncome, "Average income"), Dom.option(AreaParam.higherEducation, "Higher education"), ], id: "area-color-parameter", styles: { border: "1px solid #ddd", borderRadius: "4px", fontSize: "0.9rem", padding: "0.5rem", width: "100%", }, }), ), ], styles: { display: "flex", flexDirection: "column" }, }), ), ], styles: { borderBottom: "1px solid #eee", paddingBottom: "1rem", }, }), ), // Filters Section Dom.div( new DomOptions({ children: [ Dom.heading( 3, "Filters", new DomOptions({ styles: { color: "#333", fontSize: "1.1rem", margin: "1rem 0 1rem 0", }, }), ), Dom.div( new DomOptions({ children: [ Widgets.numberFilter("min-price", "Min price (€)", (v) => { this.#filters.minPrice = v ?? 0; this.#onFilterChange(); }), Widgets.numberFilter("max-price", "Max price (€)", (v) => { this.#filters.maxPrice = v ?? Number.POSITIVE_INFINITY; this.#onFilterChange(); }), ], id: "price-row", styles: { display: "flex", gap: "0.5rem", }, }), ), Widgets.numberFilter("min-year", "Min year", (v) => { this.#filters.minYear = v ?? 0; this.#onFilterChange(); }), Widgets.numberFilter("min-area", "Min area (m²)", (v) => { this.#filters.minArea = v ?? 0; this.#onFilterChange(); }), Dom.div( new DomOptions({ children: [ Dom.label( "district-select", "Districts", new DomOptions({ styles: { fontSize: "0.85rem", fontWeight: "bold", marginBottom: "0.25rem", }, }), ), Dom.select( (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: [], id: "district-select", styles: { border: "1px solid #ddd", borderRadius: "4px", minHeight: "120px", padding: "0.5rem", width: "100%", }, }), ), ], id: "district-multi-select", styles: { display: "flex", flexDirection: "column", marginTop: "1rem" }, }), ), ], styles: { borderBottom: "1px solid #eee", paddingBottom: "1rem", }, }), ), // Weights Section Dom.div( new DomOptions({ children: [ Dom.heading( 3, "Weights", new DomOptions({ styles: { color: "#333", fontSize: "1.1rem", margin: "1rem 0 1rem 0", }, }), ), Widgets.slider( "w-price", "Price weight", "price", this.#weights.price, this.#onWeightChange, ), Widgets.slider( "w-market", "Market distance", "distanceMarket", this.#weights.distanceMarket, this.#onWeightChange, ), Widgets.slider( "w-school", "School distance", "distanceSchool", this.#weights.distanceSchool, this.#onWeightChange, ), Widgets.slider( "w-crime", "Crime rate", "crimeRate", this.#weights.crimeRate, this.#onWeightChange, ), Widgets.slider( "w-safety", "Safety index", "safety", this.#weights.safety, this.#onWeightChange, ), Widgets.slider( "w-students", "S2 students", "s2Students", this.#weights.s2Students, this.#onWeightChange, ), Widgets.slider( "w-railway", "Railway distance", "distanceRailway", this.#weights.distanceRailway, this.#onWeightChange, ), Widgets.slider( "w-year", "Construction year", "constructionYear", this.#weights.constructionYear, this.#onWeightChange, ), ], }), ), ], id: "sidebar-content", }), ), ], id: "sidebar", styles: { background: "#fff", borderRight: "1px solid #ddd", display: "flex", flexDirection: "column", flexShrink: "0", overflowY: "auto", padding: "1rem", position: "relative", transition: "width 0.3s ease", width: "300px", }, }), ); } /** * Get the root DOM element * @returns {HTMLElement} */ render() { return this.#rootElement; } /** Show the sidebar */ show() { if (this.#collapsed) { this.toggle(); } } /** Hide the sidebar */ hide() { if (!this.#collapsed) { this.toggle(); } } toggle() { this.#collapsed = !this.#collapsed; const sidebarContent = this.#rootElement.querySelector("#sidebar-content"); const toggleButton = this.#rootElement.querySelector("#sidebar-toggle"); if (this.#collapsed) { this.#rootElement.style.width = "0"; this.#rootElement.style.padding = "0"; if (sidebarContent) sidebarContent.style.display = "none"; if (toggleButton) { toggleButton.textContent = "☰"; toggleButton.style.right = "0.5rem"; } } else { this.#rootElement.style.width = "300px"; this.#rootElement.style.padding = "1rem"; if (sidebarContent) sidebarContent.style.display = "block"; if (toggleButton) { toggleButton.textContent = "☰"; toggleButton.style.right = "0.5rem"; } } } /** * Update district options in the multi-select * @param {House[]} houses */ updateDistricts(houses) { const districtOptions = this.#renderDistrictOptions(houses); const districtSelect = this.#rootElement.querySelector("#district-select"); if (districtSelect) { districtSelect.append(...districtOptions); } } /** * Set the area color parameter in the dropdown * @param {string} param */ setAreaColorParameter(param) { const areaColorSelect = this.#rootElement.querySelector("#area-color-parameter"); if (areaColorSelect) { areaColorSelect.value = param; } } /** * Render district options for multi-select * @param {House[]} houses * @returns {HTMLOptionElement[]} */ #renderDistrictOptions(houses) { const houseDistricts = [...new Set(houses.map((h) => h.district).filter((d) => d))].sort(); return houseDistricts.map((districtName) => Dom.option(districtName, districtName)); } } export class Modal { /** @type {HTMLDialogElement} */ #dialog; /** @type {AbortController} */ #abortController; /** @type {number | undefined} */ #timer; /** @type {boolean} */ #persistent; /** @type {() => void} */ #onHide; /** @type {() => void} */ #onClearMapTimer; /** * Build modal content for a house * @param {House} house * @returns {DocumentFragment} */ static content(house) { const frag = document.createDocumentFragment(); frag.appendChild( Dom.div( new DomOptions({ children: [ Dom.heading( 2, house.address, new DomOptions({ styles: { color: "#333", fontSize: "20px", margin: "0" }, }), ), Dom.span( `Score: ${house.scores.current.toFixed(1)}`, new DomOptions({ styles: { background: "#e8f5e9", borderRadius: "4px", color: "#2e7d32", fontSize: "16px", fontWeight: "bold", padding: "4px 8px", }, }), ), ], id: "modal-header", styles: { alignItems: "center", display: "flex", justifyContent: "space-between", marginBottom: "20px", }, }), ), ); const grid = Dom.div( new DomOptions({ styles: { display: "grid", gap: "15px", gridTemplateColumns: "repeat(2,1fr)", marginBottom: "20px", }, }), ); const details = [ { label: "Price", value: `${house.price} €` }, { label: "Building Type", value: house.buildingType }, { label: "Construction Year", value: house.constructionYear?.toString() ?? "N/A" }, { label: "Living Area", value: `${house.livingArea} m²` }, { label: "District", value: house.district }, { label: "Rooms", value: house.rooms?.toString() ?? "N/A" }, ]; for (const { label, value } of details) { const item = Dom.div( new DomOptions({ children: [ Dom.span( label, new DomOptions({ styles: { fontSize: "14px", fontWeight: "bold", marginBottom: "4px", marginRight: "4px", }, }), ), Dom.span(value, new DomOptions({ styles: { color: "#333", fontSize: "14px" } })), ], }), ); grid.appendChild(item); } frag.appendChild(grid); frag.appendChild( Dom.div( new DomOptions({ children: [ Dom.span( "Description", new DomOptions({ styles: { fontSize: "14px", fontWeight: "bold", marginBottom: "5px" }, }), ), Dom.p( house.description ?? "No description available.", new DomOptions({ styles: { color: "#333", fontSize: "14px", lineHeight: "1.4", marginTop: "5px" }, }), ), ], styles: { marginBottom: "20px" }, }), ), ); if (house.images?.length) { const imgSect = Dom.div( new DomOptions({ children: [ Dom.div( new DomOptions({ id: "img_title", styles: { fontSize: "14px", fontWeight: "bold", marginBottom: "10px" }, }), ), Dom.div( new DomOptions({ children: house.images.slice(0, 3).map((src) => { return Dom.img( src, new DomOptions({ attributes: { loading: "lazy" }, styles: { borderRadius: "4px", flexShrink: "0", height: "100px" }, }), ); }), styles: { display: "flex", gap: "10px", overflowX: "auto", paddingBottom: "5px" }, }), ), ], styles: { marginBottom: "20px" }, }), ); frag.appendChild(imgSect); } return frag; } /** * @param {House} house * @param {boolean} persistent * @param {object} positionStyles * @param {() => void} onHide * @param {() => void} onClearMapTimer */ constructor(house, persistent, positionStyles, onHide, onClearMapTimer) { this.#persistent = persistent; this.#onHide = onHide; this.#onClearMapTimer = onClearMapTimer; this.#abortController = new AbortController(); this.#dialog = document.createElement("dialog"); Object.assign( this.#dialog.style, { background: "white", border: "none", borderRadius: "8px", boxShadow: "0 4px 20px rgba(0,0,0,0.2)", maxHeight: "80vh", maxWidth: "600px", overflowY: "auto", padding: "20px", position: "fixed", top: "50%", transform: "translateY(-50%)", width: "90%", zIndex: "1000", }, positionStyles, ); this.#dialog.append( Dom.button( "x", () => this.hide(), new DomOptions({ id: "close-modal-btn", styles: { background: "none", border: "none", color: "#666", cursor: "pointer", fontSize: "24px", position: "absolute", right: "10px", top: "10px", }, }), ), Modal.content(house), ); // Add event listeners with AbortController this.#dialog.addEventListener("close", () => this.hide(), { signal: this.#abortController.signal, }); this.#dialog.addEventListener( "mouseenter", () => { clearTimeout(this.#timer); this.#onClearMapTimer(); }, { signal: this.#abortController.signal }, ); this.#dialog.addEventListener( "mouseleave", () => { if (!this.#persistent) { this.#timer = window.setTimeout(() => this.hide(), 200); } }, { signal: this.#abortController.signal }, ); } render() { return this.#dialog; } show() { if (this.#persistent) { this.#dialog.showModal(); } else { this.#dialog.show(); } } hide() { clearTimeout(this.#timer); this.#dialog.close(); this.#dialog.remove(); this.#abortController.abort(); this.#onHide(); } }