aboutsummaryrefslogtreecommitdiffstats
path: root/app/components.js
diff options
context:
space:
mode:
Diffstat (limited to 'app/components.js')
-rw-r--r--app/components.js521
1 files changed, 500 insertions, 21 deletions
diff --git a/app/components.js b/app/components.js
index c952b08..3308b62 100644
--- a/app/components.js
+++ b/app/components.js
@@ -1,5 +1,338 @@
import { Dom, DomOptions, ToastType } from "dom";
import { AreaParam, Filters, House, HouseParameter, Weights } from "models";
+import { Svg, SvgOptions } from "svg";
+
+export class Histogram {
+ /** @type {HTMLElement} */
+ #rootElement;
+ /** @type {number[]} */
+ #values = [];
+ /** @type {number} */
+ #bins = 5;
+ /** @type {(min: number, max: number) => void} */
+ #onBarClick;
+ /** @type {string} */
+ #currentParameter = HouseParameter.price;
+
+ /**
+ * @param {number[]} values
+ * @param {number} bins
+ * @param {(min: number, max: number) => void} onBarClick
+ * @param {string} parameter
+ */
+ constructor(values, bins, onBarClick, parameter) {
+ this.#values = values.filter((v) => !Number.isNaN(v) && Number.isFinite(v));
+ this.#bins = bins;
+ this.#onBarClick = onBarClick;
+ this.#currentParameter = parameter;
+ this.#rootElement = this.#render();
+ }
+
+ /**
+ * Update histogram with new data
+ * @param {number[]} values
+ * @param {string} parameter
+ */
+ update(values, parameter) {
+ this.#values = values.filter((v) => !Number.isNaN(v) && Number.isFinite(v));
+ this.#currentParameter = parameter;
+ const newElement = this.#render();
+ this.#rootElement.replaceWith(newElement);
+ this.#rootElement = newElement;
+ }
+
+ /**
+ * Calculate percentile bins
+ * @returns {{min: number, max: number, count: number, values: number[]}[]}
+ */
+ #calculateBins() {
+ if (this.#values.length === 0) return [];
+
+ const sorted = [...this.#values].sort((a, b) => a - b);
+ const binSize = Math.ceil(sorted.length / this.#bins);
+ const bins = [];
+
+ for (let i = 0; i < this.#bins; i++) {
+ const startIdx = i * binSize;
+ const endIdx = Math.min((i + 1) * binSize - 1, sorted.length - 1);
+
+ if (startIdx >= sorted.length) break;
+
+ const min = sorted[startIdx];
+ const max = sorted[endIdx];
+ const binValues = sorted.slice(startIdx, endIdx + 1);
+
+ bins.push({
+ count: binValues.length,
+ max,
+ min,
+ values: binValues,
+ });
+ }
+
+ return bins;
+ }
+
+ /**
+ * Format value based on parameter type
+ * @param {number} value
+ * @returns {string}
+ */
+ #formatValue(value) {
+ switch (this.#currentParameter) {
+ case HouseParameter.price:
+ return `${(value / 1000).toFixed(0)}k€`;
+ case HouseParameter.area:
+ return `${value.toFixed(0)} m²`;
+ case HouseParameter.year:
+ return value.toFixed(0);
+ case HouseParameter.score:
+ return value.toFixed(1);
+ default:
+ return value.toFixed(0);
+ }
+ }
+
+ /**
+ * Get parameter display name
+ * @returns {string}
+ */
+ #getParameterName() {
+ switch (this.#currentParameter) {
+ case HouseParameter.price:
+ return "Price";
+ case HouseParameter.area:
+ return "Living Area";
+ case HouseParameter.year:
+ return "Construction Year";
+ case HouseParameter.score:
+ return "Score";
+ default:
+ return "Value";
+ }
+ }
+
+ /**
+ * Render histogram SVG using existing Svg methods
+ * @returns {HTMLElement}
+ */
+ #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",
+ },
+ }),
+ );
+ }
+
+ const maxCount = Math.max(...bins.map((bin) => bin.count));
+ const svgWidth = 280;
+ const svgHeight = 120;
+ const padding = { bottom: 30, left: 40, right: 10, top: 20 };
+ const chartWidth = svgWidth - padding.left - padding.right;
+ const chartHeight = svgHeight - padding.top - padding.bottom;
+ const barWidth = chartWidth / bins.length;
+
+ const children = [];
+
+ // Create title
+ children.push(
+ Svg.textXY(
+ svgWidth / 2,
+ 12,
+ `${this.#getParameterName()} Distribution`,
+ new SvgOptions({
+ attributes: {
+ "font-size": "10",
+ "font-weight": "bold",
+ "text-anchor": "middle",
+ },
+ styles: {
+ fill: "#333",
+ },
+ }),
+ ),
+ );
+
+ // Create bars
+ bins.forEach((bin, index) => {
+ const barHeight = (bin.count / maxCount) * chartHeight;
+ const x = padding.left + index * barWidth;
+ const y = padding.top + chartHeight - barHeight;
+
+ // Create bar rectangle
+ const bar = Svg.rectXY(
+ x,
+ y,
+ barWidth - 2,
+ barHeight,
+ new SvgOptions({
+ attributes: {
+ fill: "#4caf50",
+ stroke: "#388e3c",
+ "stroke-width": "0.5",
+ },
+ children: [
+ Svg.title(
+ `Range: ${this.#formatValue(bin.min)} - ${this.#formatValue(bin.max)}\nCount: ${bin.count} properties\nClick to filter this range`,
+ ),
+ ],
+ styles: {
+ cursor: "pointer",
+ transition: "fill 0.2s ease",
+ },
+ }),
+ );
+
+ // Add event listeners
+ bar.addEventListener("mouseenter", () => {
+ bar.setAttribute("fill", "#2e7d32");
+ });
+ bar.addEventListener("mouseleave", () => {
+ bar.setAttribute("fill", "#4caf50");
+ });
+ bar.addEventListener("click", () => {
+ this.#onBarClick(bin.min, bin.max);
+ });
+
+ children.push(bar);
+
+ // Create X-axis label
+ const shortMin = this.#formatValue(bin.min).replace("k€", "k");
+ const shortMax = this.#formatValue(bin.max).replace("k€", "k");
+ const labelText = `${shortMin}-${shortMax}`;
+
+ const labelX = x + barWidth / 2;
+ let labelY = svgHeight - 8;
+ let transform = "";
+
+ if (labelText.length > 8) {
+ transform = `rotate(45 ${labelX} ${labelY})`;
+ labelY = svgHeight - 12;
+ }
+
+ children.push(
+ Svg.textXY(
+ labelX,
+ labelY,
+ labelText,
+ new SvgOptions({
+ attributes: {
+ "font-size": "8",
+ "text-anchor": "middle",
+ transform: transform,
+ },
+ styles: {
+ fill: "#666",
+ },
+ }),
+ ),
+ );
+ });
+
+ // Create Y-axis line
+ children.push(
+ Svg.line(
+ padding.left,
+ padding.top,
+ padding.left,
+ padding.top + chartHeight,
+ new SvgOptions({
+ attributes: {
+ stroke: "#ccc",
+ "stroke-width": "1",
+ },
+ }),
+ ),
+ );
+
+ // Create Y-axis labels and ticks
+ const yStep = maxCount / 3;
+ for (let i = 0; i <= 3; i++) {
+ const count = Math.round(i * yStep);
+ const y = padding.top + chartHeight - (i * chartHeight) / 3;
+
+ // Y-axis label
+ children.push(
+ Svg.textXY(
+ padding.left - 5,
+ y,
+ count.toString(),
+ new SvgOptions({
+ attributes: {
+ dy: "0.3em",
+ "font-size": "8",
+ "text-anchor": "end",
+ },
+ styles: {
+ fill: "#666",
+ },
+ }),
+ ),
+ );
+
+ // Y-axis tick
+ children.push(
+ Svg.line(
+ padding.left - 3,
+ y,
+ padding.left,
+ y,
+ new SvgOptions({
+ attributes: {
+ stroke: "#ccc",
+ "stroke-width": "1",
+ },
+ }),
+ ),
+ );
+ }
+
+ // Create main SVG element
+ const svgElement = Svg.svg(
+ new SvgOptions({
+ attributes: {
+ height: svgHeight.toString(),
+ width: svgWidth.toString(),
+ },
+ children: children,
+ styles: {
+ background: "#fafafa",
+ border: "1px solid #eee",
+ borderRadius: "4px",
+ },
+ }),
+ );
+
+ return Dom.div(
+ new DomOptions({
+ children: [svgElement],
+ styles: {
+ background: "white",
+ border: "1px solid #e0e0e0",
+ borderRadius: "4px",
+ margin: "1rem 0",
+ padding: "0.5rem",
+ },
+ }),
+ );
+ }
+
+ /**
+ * Get the root DOM element
+ * @returns {HTMLElement}
+ */
+ render() {
+ return this.#rootElement;
+ }
+}
export class Widgets {
/**
@@ -111,10 +444,11 @@ export class Widgets {
* Create a number filter input
* @param {string} id
* @param {string} labelText
+ * @param {string|number} value
* @param {(value: number | null) => void} onChange
* @returns {HTMLElement}
*/
- static numberFilter(id, labelText, onChange) {
+ static numberFilter(id, labelText, value, onChange) {
return Dom.div(
new DomOptions({
children: [
@@ -132,8 +466,8 @@ export class Widgets {
const raw = target.value.trim();
onChange(raw === "" ? null : Number(raw));
},
+ value,
"any",
- "",
new DomOptions({
id,
styles: {
@@ -152,6 +486,10 @@ export class Widgets {
}
export class Sidebar {
+ /** @type {Histogram} */
+ #histogram;
+ /** @type {House[]|null} */
+ #allHouses = [];
/** @type {HTMLElement} */
#rootElement;
/** @type {boolean} */
@@ -170,6 +508,44 @@ export class Sidebar {
#onAreaColorChange;
/**
+ * @param {House[]|null} allHouses
+ * @param {Filters} filters
+ * @param {Weights} weights
+ * @param {() => void} onFilterChange
+ * @param {(key: string, value: number) => void} onWeightChange
+ * @param {(param: string) => void} onColorChange
+ * @param {(param: string) => void} onAreaColorChange
+ */
+ constructor(
+ allHouses,
+ filters,
+ weights,
+ onFilterChange,
+ onWeightChange,
+ onColorChange,
+ onAreaColorChange,
+ ) {
+ this.#allHouses = allHouses;
+ this.#filters = filters;
+ this.#weights = weights;
+ this.#onFilterChange = onFilterChange;
+ this.#onWeightChange = onWeightChange;
+ this.#onColorChange = onColorChange;
+ this.#onAreaColorChange = onAreaColorChange;
+
+ const initialParam = HouseParameter.price;
+ const initialValues = this.#allHouses?.map((house) => house.get(initialParam));
+ this.#histogram = new Histogram(
+ initialValues || [],
+ 5,
+ (min, max) => this.#handleHistogramClick(min, max),
+ initialParam,
+ );
+
+ this.#rootElement = this.#render();
+ }
+
+ /**
* @param {Weights} weights
* @param {(key: string, value: number) => void} onChange
*/
@@ -401,11 +777,11 @@ export class Sidebar {
Dom.div(
new DomOptions({
children: [
- Widgets.numberFilter("min-price", "Min price (€)", (v) => {
+ Widgets.numberFilter("min-price", "Min price (€)", filters.minPrice, (v) => {
filters.minPrice = v ?? 0;
onChange();
}),
- Widgets.numberFilter("max-price", "Max price (€)", (v) => {
+ Widgets.numberFilter("max-price", "Max price (€)", filters.maxPrice, (v) => {
filters.maxPrice = v ?? Number.POSITIVE_INFINITY;
onChange();
}),
@@ -417,11 +793,11 @@ export class Sidebar {
},
}),
),
- Widgets.numberFilter("min-year", "Min year", (v) => {
+ Widgets.numberFilter("min-year", "Min year", filters.minYear, (v) => {
filters.minYear = v ?? 0;
onChange();
}),
- Widgets.numberFilter("min-area", "Min area (m²)", (v) => {
+ Widgets.numberFilter("min-area", "Min area (m²)", filters.minArea, (v) => {
filters.minArea = v ?? 0;
onChange();
}),
@@ -476,21 +852,124 @@ export class Sidebar {
}
/**
- * @param {Filters} filters
- * @param {Weights} weights
- * @param {() => void} onFilterChange
- * @param {(key: string, value: number) => void} onWeightChange
- * @param {(param: string) => void} onColorChange
- * @param {(param: string) => void} onAreaColorChange
+ * Update histogram data
+ * @param {House[]} houses
+ * @param {string} parameter
*/
- constructor(filters, weights, onFilterChange, onWeightChange, onColorChange, onAreaColorChange) {
- this.#filters = filters;
- this.#weights = weights;
- this.#onFilterChange = onFilterChange;
- this.#onWeightChange = onWeightChange;
- this.#onColorChange = onColorChange;
- this.#onAreaColorChange = onAreaColorChange;
- this.#rootElement = this.#render();
+ updateHistogram(houses, parameter) {
+ const values = houses.map((house) => house.get(parameter));
+ if (this.#histogram) {
+ this.#histogram.update(values, parameter);
+ }
+ }
+
+ /**
+ * Handle histogram bar click
+ * @param {number} min
+ * @param {number} max
+ */
+ /**
+ * Handle histogram bar click
+ * @param {number} min
+ * @param {number} max
+ */
+ #handleHistogramClick(min, max) {
+ const param = document.getElementById("color-parameter")?.value || HouseParameter.price;
+
+ switch (param) {
+ case HouseParameter.price: {
+ this.#filters.minPrice = min;
+ this.#filters.maxPrice = max;
+ // Update the input fields to reflect the new filter values
+ const minPriceInput = document.getElementById("min-price");
+ const maxPriceInput = document.getElementById("max-price");
+ if (minPriceInput) minPriceInput.value = Math.round(min).toString();
+ if (maxPriceInput) maxPriceInput.value = Math.round(max).toString();
+ break;
+ }
+ case HouseParameter.area: {
+ this.#filters.minArea = min;
+ // Update the input field to reflect the new filter value
+ const minAreaInput = document.getElementById("min-area");
+ if (minAreaInput) minAreaInput.value = Math.round(min).toString();
+ break;
+ }
+ case HouseParameter.year: {
+ this.#filters.minYear = min;
+ // Update the input field to reflect the new filter value
+ const minYearInput = document.getElementById("min-year");
+ if (minYearInput) minYearInput.value = Math.round(min).toString();
+ break;
+ }
+ case HouseParameter.score: {
+ // Note: You might want to add score filters to your Filters class
+ console.log(`Score range: ${min} - ${max}`);
+ // If you add score filters to Filters class, you would do:
+ // this.#filters.minScore = min;
+ // this.#filters.maxScore = max;
+ break;
+ }
+ }
+ // Update the input fields to show the new filter values
+ this.#updateFilterInputs();
+
+ // Trigger the filter change to update the application
+ this.#onFilterChange();
+ }
+ /**
+ * Update filter input values to match current filters
+ */
+ #updateFilterInputs() {
+ const minPriceInput = document.getElementById("min-price");
+ const maxPriceInput = document.getElementById("max-price");
+ const minAreaInput = document.getElementById("min-area");
+ const minYearInput = document.getElementById("min-year");
+
+ if (minPriceInput)
+ minPriceInput.value = this.#filters.minPrice
+ ? Math.round(this.#filters.minPrice).toString()
+ : "";
+ if (maxPriceInput)
+ maxPriceInput.value =
+ this.#filters.maxPrice !== Number.POSITIVE_INFINITY
+ ? Math.round(this.#filters.maxPrice).toString()
+ : "";
+ if (minAreaInput)
+ minAreaInput.value = this.#filters.minArea
+ ? Math.round(this.#filters.minArea).toString()
+ : "";
+ if (minYearInput)
+ minYearInput.value = this.#filters.minYear
+ ? Math.round(this.#filters.minYear).toString()
+ : "";
+ }
+
+ /**
+ * @param {Histogram} histogram
+ */
+ static histogramSection(histogram) {
+ return Dom.div(
+ new DomOptions({
+ children: [
+ Dom.heading(
+ 3,
+ "Distribution",
+ new DomOptions({
+ styles: {
+ color: "#333",
+ fontSize: "1.1rem",
+ margin: "0 0 0.5rem 0",
+ },
+ }),
+ ),
+ histogram.render(),
+ ],
+ styles: {
+ borderBottom: "1px solid #eee",
+ paddingBottom: "1rem",
+ },
+ }),
+ );
}
/**
@@ -521,10 +1000,10 @@ export class Sidebar {
},
}),
),
- // Sidebar content
Dom.div(
new DomOptions({
children: [
+ Sidebar.histogramSection(this.#histogram),
Sidebar.dataSection(this.#onColorChange, this.#onAreaColorChange),
Sidebar.filtersSection(this.#filters, this.#onFilterChange),
Sidebar.weightSection(this.#weights, this.#onWeightChange),