aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorPetri Hienonen <petri.hienonen@gmail.com>2025-11-16 09:15:03 +0200
committerPetri Hienonen <petri.hienonen@gmail.com>2025-11-16 17:29:42 +0200
commit57bb986524388b6fc9441aea7a53ff9852380663 (patch)
treef5f369d1ae27162149cb89c065feb0503808a46e
parentb9eb804ae4f47974ad99025892dd477169809de1 (diff)
downloadhousing-57bb986524388b6fc9441aea7a53ff9852380663.tar.zst
Change dom access pattern
-rw-r--r--README.adoc10
-rw-r--r--app/components.js1891
-rw-r--r--app/dom.js558
-rw-r--r--app/index.html5
-rw-r--r--app/main.js454
-rw-r--r--app/models.js4
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) {
diff --git a/app/dom.js b/app/dom.js
index 9473bf1..6f693c8 100644
--- a/app/dom.js
+++ b/app/dom.js
@@ -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));
}