diff options
Diffstat (limited to 'app/map.js')
| -rw-r--r-- | app/map.js | 456 |
1 files changed, 173 insertions, 283 deletions
@@ -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})`); - } } |
