aboutsummaryrefslogtreecommitdiffstats
path: root/app/map.js
diff options
context:
space:
mode:
Diffstat (limited to 'app/map.js')
-rw-r--r--app/map.js456
1 files changed, 173 insertions, 283 deletions
diff --git a/app/map.js b/app/map.js
index 2259b60..2c95fb9 100644
--- a/app/map.js
+++ b/app/map.js
@@ -1,31 +1,17 @@
-import { Bounds, Collection, Feature, LineString, MultiLineString, Point } from "geom";
-import { District, House, StatisticalArea, TrainStation, TrainTracks } from "models";
+import { Bounds, Feature, FeatureCollection, LineString, MultiLineString, Point } from "geom";
+import {
+ AreaParam,
+ Collection,
+ District,
+ House,
+ HouseParameter,
+ StatisticalArea,
+ TrainStation,
+ TrainTracks,
+} from "models";
import { Svg, SvgOptions } from "svg";
/**
- * Color parameters for house markers
- * @enum {string}
- */
-export const ColorParameter = {
- area: "livingArea",
- price: "price",
- score: "score",
- year: "constructionYear",
-};
-
-/**
- * Area color parameters for statistical areas
- * @enum {string}
- */
-export const AreaColorParameter = {
- averageIncome: "averageIncome",
- foreignSpeakers: "foreignSpeakers",
- higherEducation: "higherEducation",
- none: "none",
- unemploymentRate: "unemploymentRate",
-};
-
-/**
* Math utility functions
*/
export class MapMath {
@@ -76,10 +62,8 @@ export class PanningConfig {
export class MapEl {
/** @type {SVGSVGElement} */
svg;
- /** @type {House[]} */
- #houses = [];
- /** @type {StatisticalArea[]} */
- #statAreas = [];
+ /** @type {Collection|null} */
+ #collection = null;
/** @type {SVGGElement|null} */
#housesGroup = null;
/** @type {SVGGElement|null} */
@@ -99,9 +83,6 @@ export class MapEl {
/** @type {number} */
#viewHeightMeters = 10000; // Initial view height in meters
/** @type {string} */
- #areaColorParameter = AreaColorParameter.none;
- /** @type {Object} */
- #statAreaRanges = {};
/**
* @param {Object} options
@@ -212,45 +193,15 @@ export class MapEl {
}
/**
- * Calculate min/max ranges for statistical area values
- * @param {StatisticalArea[]} statAreas
- */
- #calculateStatAreaRanges(statAreas) {
- this.#statAreaRanges = {};
-
- // Calculate ranges for each parameter type
- const parameters = [
- AreaColorParameter.foreignSpeakers,
- AreaColorParameter.unemploymentRate,
- AreaColorParameter.averageIncome,
- AreaColorParameter.higherEducation,
- ];
-
- for (const param of parameters) {
- const values = statAreas.map((area) => MapEl.#getStatisticalAreaValue(area, param));
- const min = Math.min(...values);
- const max = Math.max(...values);
- this.#statAreaRanges[param] = { max, min };
- }
- }
-
- /**
* Initialize map with empty content
- * @param {District[]} districts
- * @param {Collection} coastLine
- * @param {Collection} mainRoads
- * @param {TrainTracks[]} tracks
- * @param {TrainStation[]} stations
- * @param {House[]} houses
- * @param {StatisticalArea[]} statAreas
- * @param {string} colorParameter
+ * @param {Collection} collection
+ * @param {HouseParameter} houseParameter
+ * @param {AreaParam} areaParameter
* @returns {SVGSVGElement}
*/
- initialize(districts, coastLine, mainRoads, tracks, stations, houses, statAreas, colorParameter) {
- this.#houses = houses;
- this.#statAreas = statAreas;
- this.#calculateStatAreaRanges(statAreas);
- this.#setInitialViewBox(District.bounds(districts));
+ initialize(collection, houseParameter, areaParameter) {
+ this.#collection = collection;
+ this.#setInitialViewBox(District.bounds(collection.districts));
const transformGroup = Svg.g(
new SvgOptions({
attributes: { transform: "scale(1, -1)" },
@@ -261,12 +212,8 @@ export class MapEl {
"pointer-events": "none",
},
children: [
- ...MapEl.#getStatisticalAreas(
- statAreas,
- this.#areaColorParameter,
- this.#statAreaRanges,
- ),
- ...MapEl.#getStatisticalAreaLabels(statAreas),
+ ...MapEl.#getStatisticalAreas(collection.statisticalAreas, areaParameter),
+ ...MapEl.#getStatisticalAreaLabels(collection.statisticalAreas),
],
id: "statistical-areas",
}),
@@ -277,7 +224,10 @@ export class MapEl {
"pointer-events": "none",
"stroke-width": "0.0005",
},
- children: [...MapEl.#getCoastLine(coastLine), ...MapEl.#getRoads(mainRoads)],
+ children: [
+ ...MapEl.#getCoastLine(collection.coastLine),
+ ...MapEl.#getRoads(collection.mainRoads),
+ ],
id: "background",
}),
),
@@ -289,7 +239,7 @@ export class MapEl {
stroke: "rgba(255, 68, 68, 1)",
"stroke-width": "0.001",
},
- children: MapEl.#getTracks(tracks),
+ children: MapEl.#getTracks(collection.trainTracks),
id: "train-tracks",
}),
),
@@ -302,14 +252,43 @@ export class MapEl {
stroke: "rgba(204, 0, 0, 1)",
"stroke-width": "0.001",
},
- children: MapEl.#getStations(stations),
+ children: MapEl.#getStations(collection.trainStations),
id: "train-stations",
}),
),
Svg.g(
new SvgOptions({
+ attributes: {
+ fill: "rgba(255, 255, 68, 1)",
+ "pointer-events": "none",
+ r: "0.003",
+ stroke: "rgba(255, 255, 0, 1)",
+ "stroke-width": "0.001",
+ },
+ children: MapEl.#getStations(collection.lightRailStops),
+ id: "light_rail",
+ }),
+ ),
+ Svg.g(
+ new SvgOptions({
+ attributes: {
+ fill: "rgba(0, 255, 68, 1)",
+ "pointer-events": "none",
+ r: "0.003",
+ stroke: "rgba(0, 255, 0, 1)",
+ "stroke-width": "0.001",
+ },
+ children: MapEl.#renderFeatures(collection.jokerTramStops),
+ id: "tram-stations",
+ }),
+ ),
+ Svg.g(
+ new SvgOptions({
attributes: {},
- children: [...MapEl.#getDistricts(districts), ...MapEl.#getDistrictLabels(districts)],
+ children: [
+ ...MapEl.#getDistricts(collection.districts),
+ ...MapEl.#getDistrictLabels(collection.districts),
+ ],
id: "districts",
}),
),
@@ -322,7 +301,7 @@ export class MapEl {
"stroke-linecap": "butt",
"stroke-width": "0.001",
},
- children: this.getHouses(houses, colorParameter),
+ children: this.#getHouses(collection.houses, houseParameter),
id: "houses",
}),
),
@@ -332,8 +311,8 @@ export class MapEl {
);
this.svg.append(transformGroup);
- const coastBounds = Bounds.union(coastLine.features.map((f) => f.geometry.bounds()));
- const roadBounds = Bounds.union(mainRoads.features.map((f) => f.geometry.bounds()));
+ const coastBounds = Bounds.union(collection.coastLine.features.map((f) => f.geometry.bounds()));
+ const roadBounds = Bounds.union(collection.mainRoads.features.map((f) => f.geometry.bounds()));
this.#fullBounds = Bounds.union([coastBounds, roadBounds]);
return this.svg;
}
@@ -641,25 +620,32 @@ export class MapEl {
/**
* Set houses data and render markers
* @param {House[]} houses
- * @param {ColorParameter} colorParameter
+ * @param {HouseParameter} param
*/
- getHouses(houses, colorParameter) {
+ #getHouses(houses, param) {
+ const values = houses.map((house) => house.get(param)).sort();
+ const range = { max: Math.max(...values), min: Math.min(...values) };
+ switch (param) {
+ case HouseParameter.price: // No prices available for each house. Take some from the bottom
+ range.min = values[Math.floor(values.length * 0.2)];
+ range.max = values[Math.floor(values.length * 0.8)];
+ }
return houses.map((house) => {
+ const value = house.get(param);
+ const normalized = MapMath.normalize(value, range.min, range.max);
const circle = Svg.circle(
house.coordinates,
new SvgOptions({
attributes: {
"data-id": house.id,
- fill: MapEl.#getHouseColor(house, colorParameter),
+ fill: Color.ocean(normalized),
},
+ children: [
+ Svg.title(`${house.address}, ${house.district}\n€${house.price.toLocaleString()}`),
+ ],
classes: ["house-marker"],
}),
);
-
- const tooltipText = `${house.address}, ${house.district}\n€${house.price.toLocaleString()}`;
- const title = Svg.title(tooltipText);
- circle.appendChild(title);
-
circle.addEventListener("mouseenter", () => {
circle.setAttribute("r", "0.005");
clearTimeout(this.#modalTimer);
@@ -752,42 +738,38 @@ export class MapEl {
/**
* Set statistical areas data and render polygons
* @param {StatisticalArea[]} statAreas
- * @param {string} areaColorParameter
- * @param {Object} ranges
+ * @param {string} paramName
*/
- static #getStatisticalAreas(statAreas, areaColorParameter, ranges) {
+ static #getStatisticalAreas(statAreas, paramName) {
+ const values = statAreas.map((area) => area.getValue(paramName));
+ const range = { max: Math.max(...values), min: Math.min(...values) };
return statAreas.map((area) => {
- const color = MapEl.#getStatisticalAreaColor(area, areaColorParameter, ranges);
- const value = MapEl.#getStatisticalAreaValue(area, areaColorParameter);
-
- const poly = Svg.polygon(
+ const value = area.getValue(paramName);
+ const normalized = MapMath.normalize(value, range.min, range.max);
+ return Svg.polygon(
area.polygon.simplify(30),
new SvgOptions({
attributes: {
"data-id": area.id,
- fill: color,
+ fill: !(paramName === AreaParam.none)
+ ? Color.fall(normalized, true)
+ : "rgba(0, 0, 0, 0)",
"pointer-events": "none",
stroke: "rgba(0, 0, 0, 0.3)",
"stroke-width": "0.0003",
},
+ children: [Svg.title(`${area.properties.nimi}\n${area.getDisplay(paramName)}`)],
}),
);
-
- // Add tooltip with area name and value
- const tooltipText = `${area.properties.nimi}\n${MapEl.#getStatisticalAreaDisplayText(areaColorParameter, value)}`;
- const title = Svg.title(tooltipText);
- poly.appendChild(title);
-
- return poly;
});
}
/**
* Set statistical area labels
- * @param {StatisticalArea[]} statAreas
+ * @param {StatisticalArea[]} areas
*/
- static #getStatisticalAreaLabels(statAreas) {
- return statAreas.map((area) => {
+ static #getStatisticalAreaLabels(areas) {
+ return areas.map((area) => {
const center = area.centroid;
return Svg.text(
center,
@@ -807,72 +789,7 @@ export class MapEl {
}
/**
- * Get color for statistical area based on parameter value
- * @param {StatisticalArea} area
- * @param {string} areaColorParameter
- * @param {Object} ranges
- * @returns {string}
- */
- static #getStatisticalAreaColor(area, areaColorParameter, ranges) {
- if (areaColorParameter === AreaColorParameter.none) {
- return "rgba(0, 0, 0, 0)"; // Transparent
- }
-
- const value = MapEl.#getStatisticalAreaValue(area, areaColorParameter);
- const range = ranges[areaColorParameter];
- const normalized = range ? MapMath.normalize(value, range.min, range.max) : 0;
- return Color.get("fall", normalized, true);
- }
-
- /**
- * Get value for statistical area based on parameter
- * @param {StatisticalArea} area
- * @param {string} areaColorParameter
- * @returns {number}
- */
- static #getStatisticalAreaValue(area, areaColorParameter) {
- const props = area.properties;
-
- switch (areaColorParameter) {
- case AreaColorParameter.foreignSpeakers:
- return props.vr_kiel_vier / props.vr_vakiy;
- case AreaColorParameter.unemploymentRate:
- return props.tp_tyotaste;
- case AreaColorParameter.averageIncome:
- return props.tu_kesk;
- case AreaColorParameter.higherEducation:
- return props.ko_yl_kork / props.ko_25_;
- default:
- return 0;
- }
- }
-
- /**
- * Get display text for statistical area tooltip
- * @param {string} areaColorParameter
- * @param {number} value
- * @returns {string}
- */
- static #getStatisticalAreaDisplayText(areaColorParameter, value) {
- if (!(typeof value === "number")) {
- return "NaN";
- }
- switch (areaColorParameter) {
- case AreaColorParameter.foreignSpeakers:
- return `Foreign speakers: ${(value * 100).toFixed(1)}%`;
- case AreaColorParameter.unemploymentRate:
- return `Unemployment rate: ${value.toFixed(1)}%`;
- case AreaColorParameter.averageIncome:
- return `Average income: ${Math.round(value).toLocaleString()} €`;
- case AreaColorParameter.higherEducation:
- return `Higher education: ${(value * 100).toFixed(1)}%`;
- default:
- return "";
- }
- }
-
- /**
- * @param {Collection} roads
+ * @param {FeatureCollection} roads
*/
static #getRoads(roads) {
return roads.features
@@ -885,7 +802,7 @@ export class MapEl {
}
/**
- * @param {Collection} coastline
+ * @param {FeatureCollection} coastline
*/
static #getCoastLine(coastline) {
return coastline.features
@@ -898,6 +815,24 @@ export class MapEl {
}
/**
+ * @param {FeatureCollection} c
+ */
+ static #renderFeatures(c) {
+ return c.features
+ .map((feature) => {
+ if (feature.geometry instanceof MultiLineString) {
+ return Svg.path(new LineString(feature.geometry.simplify(30).coordinates.flat()));
+ } else if (feature.geometry instanceof LineString) {
+ return Svg.path(feature.geometry.simplify(30));
+ } else if (feature.geometry instanceof Point) {
+ return Svg.circle(feature.geometry);
+ }
+ return null;
+ })
+ .filter((x) => x !== null);
+ }
+
+ /**
* Set train infrastructure data
* @param {TrainTracks[]} tracks
*/
@@ -925,42 +860,48 @@ export class MapEl {
/**
* Update house colors based on current color parameter
- * @param {ColorParameter} colorParameter
+ * @param {HouseParameter} param
*/
- setColorParameter(colorParameter) {
+ updateHousesColor(param) {
+ const values = this.#collection?.houses.map((house) => house.get(param)).sort();
+ const range = { max: Math.max(...values), min: Math.min(...values) };
+ switch (param) {
+ case HouseParameter.price: // No prices available for each house. Take some from the bottom
+ range.min = values[Math.floor(values.length * 0.2)];
+ range.max = values[Math.floor(values.length * 0.8)];
+ }
const markers = this.#housesGroup?.querySelectorAll(".house-marker");
markers?.forEach((marker) => {
const houseId = marker.id;
- const house = this.#houses.find((h) => h.id === houseId);
+ const house = this.#collection?.houses.find((h) => h.id === houseId);
if (house) {
- const color = MapEl.#getHouseColor(house, colorParameter);
- marker.setAttribute("fill", color);
+ const value = house.get(param);
+ const normalized = MapMath.normalize(value, range.min, range.max);
+ marker.setAttribute("fill", Color.ocean(normalized));
}
});
}
/**
* Update statistical area colors based on current area color parameter
- * @param {string} areaColorParameter
+ * @param {AreaParam} param
*/
- setAreaColorParameter(areaColorParameter) {
- this.#areaColorParameter = areaColorParameter;
-
+ updateArea(param) {
+ const values = this.#collection?.statisticalAreas.map((area) => area.getValue(param));
+ const range = { max: Math.max(...values), min: Math.min(...values) };
const statAreaPolygons = this.svg.querySelectorAll("#statistical-areas polygon");
statAreaPolygons.forEach((polygon) => {
const areaId = polygon.getAttribute("data-id");
- const area = this.#statAreas.find((a) => a.id === areaId);
+ const area = this.#collection?.statisticalAreas.find((a) => a.id === areaId);
if (area) {
- const color = MapEl.#getStatisticalAreaColor(
- area,
- areaColorParameter,
- this.#statAreaRanges,
+ const value = area.getValue(param);
+ const normalized = MapMath.normalize(value, range.min, range.max);
+ polygon.setAttribute(
+ "fill",
+ !(param === AreaParam.none) ? Color.fall(normalized, true) : "rgba(0, 0, 0, 0)",
);
- polygon.setAttribute("fill", color);
- // Update tooltip
- const value = MapEl.#getStatisticalAreaValue(area, areaColorParameter);
- const tooltipText = `${area.properties.nimi}\n${MapEl.#getStatisticalAreaDisplayText(areaColorParameter, value)}`;
+ const tooltipText = `${area.properties.nimi}\n${area.getDisplay(param)}`;
const title = polygon.querySelector("title");
if (title) {
title.textContent = tooltipText;
@@ -997,44 +938,6 @@ export class MapEl {
clearModalTimer() {
clearTimeout(this.#modalTimer);
}
-
- /**
- * Get color for house based on parameter value
- * @param {House} house
- * @param {ColorParameter} colorParameter
- * @returns {string}
- */
- static #getHouseColor(house, colorParameter) {
- let value, min, max;
-
- switch (colorParameter) {
- case ColorParameter.price:
- value = house.price;
- min = 0;
- max = 1000000;
- break;
- case ColorParameter.score:
- value = house.scores.current;
- min = 0;
- max = 100;
- break;
- case ColorParameter.year:
- value = house.constructionYear || 1950;
- min = 1950;
- max = new Date().getFullYear();
- break;
- case ColorParameter.area:
- value = house.livingArea;
- min = 0;
- max = 200;
- break;
- default:
- return "rgba(76, 175, 80, 1)";
- }
-
- const normalized = MapMath.normalize(value, min, max);
- return Color.get("ocean", normalized);
- }
}
/**
@@ -1043,30 +946,55 @@ export class MapEl {
*/
export class Color {
/**
- * Get color from specified colormap
- * @param {string} colormap - Name of colormap ('fall', 'ocean', 'bubblegum', 'lilac')
* @param {number} value - Normalized value between 0 and 1
* @param {boolean} [reverse=false] - Reverse the colormap
* @returns {string} RGBA color string
*/
- static get(colormap, value, reverse = false) {
+ static fall(value, reverse = false) {
+ if (Number.isNaN(value) || value > 1 || value < 0) {
+ throw new Error(`Fall, input must be a number between [0,1], got ${value}`);
+ }
+ const normalizedT = reverse ? 1 - value : value;
+ return Color.#fall(normalizedT);
+ }
+
+ /**
+ * @param {number} value - Normalized value between 0 and 1
+ * @param {boolean} [reverse=false] - Reverse the colormap
+ * @returns {string} RGBA color string
+ */
+ static ocean(value, reverse = false) {
+ if (Number.isNaN(value) || value > 1 || value < 0) {
+ throw new Error(`Ocean, input must be a number between [0,1], got ${value}`);
+ }
+ const normalizedT = reverse ? 1 - value : value;
+ return Color.#ocean(normalizedT);
+ }
+
+ /**
+ * @param {number} value - Normalized value between 0 and 1
+ * @param {boolean} [reverse=false] - Reverse the colormap
+ * @returns {string} RGBA color string
+ */
+ static lilac(value, reverse = false) {
if (Number.isNaN(value) || value > 1 || value < 0) {
- throw new Error(`Input must be a number between [0,1] ${value}`);
+ throw new Error(`Ocean, input must be a number between [0,1], got ${value}`);
}
const normalizedT = reverse ? 1 - value : value;
+ return Color.#lilac(normalizedT);
+ }
- switch (colormap.toLowerCase()) {
- case "fall":
- return Color.#fall(normalizedT);
- case "ocean":
- return Color.#ocean(normalizedT);
- case "bubblegum":
- return Color.#bubblegum(normalizedT);
- case "lilac":
- return Color.#lilac(normalizedT);
- default:
- throw new Error(`Unknown colormap: ${colormap}`);
+ /**
+ * @param {number} value - Normalized value between 0 and 1
+ * @param {boolean} [reverse=false] - Reverse the colormap
+ * @returns {string} RGBA color string
+ */
+ static bubblegum(value, reverse = false) {
+ if (Number.isNaN(value) || value > 1 || value < 0) {
+ throw new Error(`Ocean, input must be a number between [0,1], got ${value}`);
}
+ const normalizedT = reverse ? 1 - value : value;
+ return Color.#bubblegum(normalizedT);
}
/**
@@ -1185,42 +1113,4 @@ export class Color {
return `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, 1)`;
}
-
- /**
- * Get all available colormap names
- * @returns {string[]} Array of colormap names
- */
- static getColormapNames() {
- return ["fall", "ocean", "bubblegum", "lilac"];
- }
-
- /**
- * Generate a color scale for testing/visualization
- * @param {string} colormap - Name of colormap
- * @param {number} steps - Number of steps in the scale
- * @param {boolean} [reverse=false] - Reverse the colormap
- * @returns {string[]} Array of RGBA colors
- */
- static generateColorScale(colormap, steps = 10, reverse = false) {
- const colors = [];
- for (let i = 0; i < steps; i++) {
- const t = i / (steps - 1);
- colors.push(Color.get(colormap, t, reverse));
- }
- return colors;
- }
-
- /**
- * Get color with custom alpha value
- * @param {string} colormap - Name of colormap
- * @param {number} value - Normalized value between 0 and 1
- * @param {number} alpha - Alpha value between 0 and 1
- * @param {boolean} [reverse=false] - Reverse the colormap
- * @returns {string} RGBA color string
- */
- static getColorWithAlpha(colormap, value, alpha, reverse = false) {
- const color = Color.get(colormap, value, reverse);
- // Replace the alpha value in the rgba string
- return color.replace(/[\d.]+\)$/, `${alpha})`);
- }
}