aboutsummaryrefslogtreecommitdiffstats
path: root/app/components.js
diff options
context:
space:
mode:
Diffstat (limited to 'app/components.js')
-rw-r--r--app/components.js1891
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) {