diff options
| author | Petri Hienonen <petri.hienonen@gmail.com> | 2025-11-13 18:12:17 +0200 |
|---|---|---|
| committer | Petri Hienonen <petri.hienonen@gmail.com> | 2025-11-13 18:12:17 +0200 |
| commit | e4fdd8457d2d320eea502f0801fc22eceb8947b1 (patch) | |
| tree | 110530c498b276085bb409a537a3e2174d53d435 | |
| parent | 2113f8269423932fa76ae4f822f77a07dd703266 (diff) | |
| download | housing-e4fdd8457d2d320eea502f0801fc22eceb8947b1.tar.zst | |
Nothing
| -rw-r--r-- | README.adoc | 2 | ||||
| -rw-r--r-- | app/components.js | 57 | ||||
| -rw-r--r-- | app/dom.js | 14 | ||||
| -rw-r--r-- | app/geometry.js | 56 | ||||
| -rw-r--r-- | app/main.js | 47 | ||||
| -rw-r--r-- | app/map.js | 34 | ||||
| -rw-r--r-- | app/models.js | 78 |
7 files changed, 155 insertions, 133 deletions
diff --git a/README.adoc b/README.adoc index 57e26bb..fd1cc7b 100644 --- a/README.adoc +++ b/README.adoc @@ -62,7 +62,7 @@ go run main.go == Next steps -- Implement additional map features, "metro", "pikaraitiotie", "koulut", "kaupat". +- Implement additional map features: "koulut", "päiväkodit" - Make the weight calculation work. - Add links to house details on the service. Add additional images - Images on modal open on click to a new window diff --git a/app/components.js b/app/components.js index 6531206..1c8c6aa 100644 --- a/app/components.js +++ b/app/components.js @@ -1,5 +1,5 @@ import { Dom, DomOptions, ToastType } from "dom"; -import { AreaParam, District, Filters, House, HouseParameter, Weights } from "models"; +import { AreaParam, Filters, House, HouseParameter, Weights } from "models"; export class Widgets { /** @@ -422,7 +422,7 @@ export class Sidebar { children: [ Dom.heading( 3, - "Weights", + "Scoring", new DomOptions({ styles: { color: "#333", @@ -715,34 +715,67 @@ export class Modal { const imgSect = Dom.div( new DomOptions({ children: [ - Dom.div( + Dom.span( + "Images", new DomOptions({ - id: "img_title", - styles: { fontSize: "14px", fontWeight: "bold", marginBottom: "10px" }, + styles: { + fontSize: "14px", + fontWeight: "bold", + marginBottom: "10px", + }, }), ), Dom.div( new DomOptions({ children: house.images.slice(0, 3).map((src) => { - return Dom.img( - src, + // Wrap image in anchor tag that opens in new tab + return Dom.a( new DomOptions({ - attributes: { loading: "lazy" }, - styles: { borderRadius: "4px", flexShrink: "0", height: "100px" }, + attributes: { + href: src, + 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: { display: "flex", gap: "10px", overflowX: "auto", paddingBottom: "5px" }, + styles: { + display: "flex", + gap: "10px", + overflowX: "auto", + paddingBottom: "5px", + }, }), ), ], styles: { marginBottom: "20px" }, }), ); + frag.appendChild(imgSect); } - return frag; } @@ -120,6 +120,20 @@ export class Dom { } /** + * Create a `<a>` + * @param {DomOptions} o + */ + static a(o) { + const link = document.createElement("a"); + 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.children) link.append(...o.children); + return link; + } + + /** * Create a `<label>` * @param {string} to * @param {string} text diff --git a/app/geometry.js b/app/geometry.js index 0952bbb..37bb34f 100644 --- a/app/geometry.js +++ b/app/geometry.js @@ -65,6 +65,17 @@ export class Bounds { } /** + * @enum {string} + */ +export const GeometryType = { + linestring: "Line", + multilinestring: "MultiLineString", + multipolygon: "MultiPolygon", + point: "Point", + polygon: "Polygon", +}; + +/** * @typedef {[number, number]} Coordinate - [longitude, latitude] in decimal degrees */ @@ -133,6 +144,24 @@ export class Geometry { /** * Deserialize geometry from GeoJSON + * @param {Point|LineString|Polygon|MultiLineString} geometry + */ + static toEnum(geometry) { + if (geometry instanceof Point) { + return GeometryType.point; + } else if (geometry instanceof LineString) { + return GeometryType.linestring; + } else if (geometry instanceof MultiLineString) { + return GeometryType.multilinestring; + } else if (geometry instanceof Polygon) { + return GeometryType.polygon; + } else { + throw new Error(`Invalid GeoJSON object: missing required 'type' property`); + } + } + + /** + * Deserialize geometry from GeoJSON * @param {{type: string, coordinates?: any, [key: string]: any}} geojson * @returns {Point|LineString|Polygon|MultiLineString} */ @@ -161,13 +190,11 @@ export class Geometry { static bounds(geometry) { if (geometry instanceof Point) { return new Bounds(geometry.lng, geometry.lat, geometry.lng, geometry.lat); - } - - if (geometry instanceof LineString) { + } else if (geometry instanceof LineString) { return Geometry.calculateBoundsFromCoords(geometry.coordinates); - } - - if (geometry instanceof Polygon) { + } else if (geometry instanceof MultiLineString) { + return Geometry.calculateBoundsFromCoords(geometry.coordinates); + } else if (geometry instanceof Polygon) { return Geometry.calculateBoundsFromCoords(geometry.rings[0]); } throw new Error(`Unsupported geometry type: ${geometry}`); @@ -1028,12 +1055,14 @@ export class Polygon extends Geometry { export class Feature { /** - * @param {Geometry} geometry - Feature geometry - * @param {Object} [properties={}] - Feature properties - * @param {string|number} [id] - Feature ID + * @param {Geometry} geometry + * @param {GeometryType} type + * @param {Object} [properties={}] + * @param {string|number} [id] */ - constructor(geometry, properties = {}, id = "") { + constructor(geometry, type, properties = {}, id = "") { this.geometry = geometry; + this.type = type; this.properties = properties; this.id = id; } @@ -1062,7 +1091,12 @@ export class Feature { const geometry = Geometry.fromGeoJSON(geojson.geometry); if (!geometry) return null; - return new Feature(geometry, geojson.properties ?? {}, geojson.id ?? ""); + return new Feature( + geometry, + Geometry.toEnum(geometry), + geojson.properties ?? {}, + geojson.id ?? "", + ); } } diff --git a/app/main.js b/app/main.js index f4227d9..9abb300 100644 --- a/app/main.js +++ b/app/main.js @@ -6,20 +6,14 @@ import { MapEl } from "map"; import { AreaParam, Collection, - District, Filters, House, HouseParameter, ScoringEngine, - StatisticalArea, - TrainStation, - TrainTracks, Weights, } from "models"; export class App { - /** @type {House[]} */ - #houses = []; /** @type {Collection|null} */ collection = null; /** @type {House[]} */ @@ -36,8 +30,6 @@ export class App { #sidebar; /** @type {Modal|null} */ #modal = null; - /** @type {boolean} */ - #persistent = false; /** @type {HouseParameter} */ #houseParameter = HouseParameter.price; /** @type {AreaParam} */ @@ -58,7 +50,7 @@ export class App { this.#filters, this.#weights, () => { - this.#filtered = this.#houses.filter((h) => h.matchesFilters(this.#filters)); + this.#filtered = this.collection?.houses.filter((h) => h.matchesFilters(this.#filters)); const filteredIds = this.#filtered.map((h) => h.id); this.#map.updateHouseVisibility(filteredIds); @@ -70,7 +62,7 @@ export class App { if (key in this.#weights) { this.#weights[/** @type {keyof Weights} */ (key)] = value; } - App.#recalculateScores(this.#houses, this.#weights); + App.#recalculateScores(this.collection?.houses, this.#weights); this.#map.updateHousesColor(this.#houseParameter); const stats = App.#getStats(this.#filtered); this.#stats.replaceWith(stats); @@ -156,10 +148,9 @@ export class App { * @param {boolean} persistent */ #showHouseModal(houseId, persistent) { - const house = this.#houses.find((h) => h.id === houseId); + const house = this.collection?.houses.find((h) => h.id === houseId); if (!house) return; - this.#persistent = persistent; this.#map.setModalPersistence(persistent); // Hide existing modal @@ -179,7 +170,6 @@ export class App { }, () => { this.#modal = null; - this.#persistent = false; this.#map.setModalPersistence(false); this.#map.clearModalTimer(); }, @@ -202,7 +192,7 @@ export class App { this.#filtered = this.collection.houses.slice(); this.#map.initialize(this.collection, this.#houseParameter, this.#areaParameter); - this.#sidebar.updateDistricts(this.#houses); + this.#sidebar.updateDistricts(this.collection.houses); this.#sidebar.setAreaColorParameter(this.#areaParameter); const stats = App.#getStats(this.#filtered); @@ -229,8 +219,19 @@ export class App { * @param {House[]} filtered */ static #getStats(filtered) { - const stats = Dom.div( + return Dom.div( new DomOptions({ + children: [ + Dom.strong(filtered.length.toString()), + document.createTextNode(" houses shown • Average score: "), + Dom.strong( + (filtered.length + ? Math.round(filtered.reduce((s, h) => s + h.scores.current, 0) / filtered.length) + : 0 + ).toString(), + ), + document.createTextNode(" • Use weights sliders to adjust scoring"), + ], id: "stats", styles: { background: "#fff", @@ -241,22 +242,6 @@ export class App { }, }), ); - const countStrong = Dom.strong(filtered.length.toString()); - const avgStrong = Dom.strong( - (filtered.length - ? Math.round(filtered.reduce((s, h) => s + h.scores.current, 0) / filtered.length) - : 0 - ).toString(), - ); - - // Append all elements - stats.append( - countStrong, - document.createTextNode(" houses shown • Average score: "), - avgStrong, - document.createTextNode(" • Use weights sliders to adjust scoring"), - ); - return stats; } } @@ -53,10 +53,12 @@ export class MapMath { * Panning and inertia configuration */ export class PanningConfig { - static DEFAULT_FRICTION = 0.995; + static DEFAULT_FRICTION = 0.85; static DEFAULT_SPEED_THRESHOLD = 0.001; - static DEFAULT_BOUNCE_FACTOR = 0.5; + static DEFAULT_BOUNCE_FACTOR = 0.3; static DEFAULT_VIEWBOX_SCALE = 1; + static PAN_SENSITIVITY = 0.7; // Lower = less sensitive panning (0-1) + static ZOOM_SENSITIVITY = 0.1; // Lower = less sensitive zooming (0-1) } export class MapEl { @@ -68,7 +70,7 @@ export class MapEl { #housesGroup = null; /** @type {SVGGElement|null} */ #statAreasGroup = null; - /** @type {Function|null} */ + /** @type {Function} */ #onHouseClick; /** @type {Function} */ #onHouseHover; @@ -479,12 +481,15 @@ export class MapEl { pointers.set(e.pointerId, { clientX: e.clientX, clientY: e.clientY }); if (isPinching && pointers.size === 2) { - // Handle pinch zoom const [p1, p2] = Array.from(pointers.values()); const currentDistance = Math.hypot(p2.clientX - p1.clientX, p2.clientY - p1.clientY); if (initialDistance > 0) { - const scaleFactor = currentDistance / initialDistance; + const rawScaleFactor = currentDistance / initialDistance; + + // Apply zoom sensitivity to pinch gestures + const sensitivity = PanningConfig.ZOOM_SENSITIVITY; + const scaleFactor = 1 + (rawScaleFactor - 1) * sensitivity; // Calculate center point between the two pointers in SVG coordinates const centerX = (p1.clientX + p2.clientX) / 2; @@ -513,8 +518,9 @@ export class MapEl { throw new Error("Unexpected"); } - const svgDx = dx * ctm.a + dy * ctm.c; - const svgDy = dx * ctm.b + dy * ctm.d; + const sensitivity = PanningConfig.PAN_SENSITIVITY; + const svgDx = dx * ctm.a + dy * ctm.c * sensitivity; + const svgDy = dx * ctm.b + dy * ctm.d * sensitivity; vx = svgDx / dt; vy = svgDy / dt; } @@ -649,9 +655,7 @@ export class MapEl { circle.addEventListener("mouseenter", () => { circle.setAttribute("r", "0.005"); clearTimeout(this.#modalTimer); - if (this.#onHouseHover) { - this.#onHouseHover(house.id, false); - } + this.#onHouseHover(house.id, false); }); circle.addEventListener("mouseleave", () => { @@ -661,18 +665,14 @@ export class MapEl { if (!this.#persistentModal && this.#onHouseHover) { this.#modalTimer = window.setTimeout(() => { - if (this.#onHouseHover) { - this.#onHouseHover(house.id, true); - } + this.#onHouseHover(house.id, true); }, 200); } }); circle.addEventListener("click", (e) => { e.stopPropagation(); - if (this.#onHouseClick) { - this.#onHouseClick(house.id, true); - this.#persistentModal = true; - } + this.#onHouseClick(house.id, true); + this.#persistentModal = true; }); return circle; }); diff --git a/app/models.js b/app/models.js index 9ab9c84..dbca0b1 100644 --- a/app/models.js +++ b/app/models.js @@ -702,28 +702,6 @@ export class House { if (filters.districts.length > 0 && !filters.districts.includes(this.district)) return false; return true; } - - /** - * Convert to GeoJSON Feature for export/display - * @returns {Feature} - */ - toFeature() { - return new Feature( - this.coordinates, - { - address: this.address, - buildingType: this.buildingType, - constructionYear: this.constructionYear, - district: this.district, - id: this.id, - livingArea: this.livingArea, - price: this.price, - rooms: this.rooms, - score: this.scores.current, - }, - this.id, - ); - } } export class Weights { @@ -760,7 +738,6 @@ export class Collection { * @param {FeatureCollection} mainRoads * @param {StatisticalArea[]} statisticalAreas * @param {FeatureCollection} jokerTramStops, - * @param {FeatureCollection} jokerTramTracks, * @param {TrainStation[]} lightRailStops, * @param {TrainTracks[]} lightRailTracks, */ @@ -804,17 +781,17 @@ export class Collection { lightRailStops, lightRailTracks, ] = await Promise.all([ - await DataProvider.getDistricts(), - await DataProvider.getHouses(), - await DataProvider.getTrainStations(), - await DataProvider.getTrainTracks(), - await DataProvider.getCoastline(), - await DataProvider.getMainRoads(), - await DataProvider.getStatisticalAreas(), - await DataProvider.getJokerTramStops(), - //await DataProvider.getJokerTramTracks(), - await DataProvider.getLightRailStops(), - await DataProvider.getLightRailTracks(), + DataProvider.getDistricts(), + DataProvider.getHouses(), + DataProvider.getTrainStations(), + DataProvider.getTrainTracks(), + DataProvider.getFeaturesFromCouch("Seutukartta_meren_rantaviiva"), + DataProvider.getFeaturesFromCouch("Seutukartta_liikenne_paatiet"), + DataProvider.getStatisticalAreas(), + await DataProvider.getFeaturesFromCouch("RaideJokeri_pysakit"), + //DataProvider.getJokerTramTracks(), + DataProvider.getLightRailStops(), + DataProvider.getLightRailTracks(), ]); return new Collection( districts, @@ -874,24 +851,17 @@ export class DataProvider { const result = await response.json(); const features = result.rows .map((row) => { - return row.doc.geometry && "type" in row.doc.geometry - ? new Feature(Geometry.fromGeoJSON(row.doc.geometry), row.doc.properties, row.doc._id) - : null; + if (row.doc.geometry && "type" in row.doc.geometry) { + const geom = Geometry.fromGeoJSON(row.doc.geometry); + return new Feature(geom, Geometry.toEnum(geom), row.doc.properties, row.doc._id); + } else { + return null; + } }) .filter((x) => x !== null); return new FeatureCollection(features); } - /** @returns {Promise<FeatureCollection>} */ - static async getCoastline() { - return await DataProvider.getFeaturesFromCouch("Seutukartta_meren_rantaviiva"); - } - - /** @returns {Promise<FeatureCollection>} */ - static async getMainRoads() { - return await DataProvider.getFeaturesFromCouch("Seutukartta_liikenne_paatiet"); - } - /** @returns {Promise<District[]>} */ static async getDistricts() { return District.fromFeatureCollection( @@ -928,11 +898,6 @@ export class DataProvider { } /** @returns {Promise<FeatureCollection>} */ - static async getJokerTramStops() { - return await DataProvider.getFeaturesFromCouch("RaideJokeri_pysakit"); - } - - /** @returns {Promise<FeatureCollection>} */ static async getJokerTramTracks() { return await DataProvider.getFeaturesFromCouch("RaideJokeri_ratalinja"); } @@ -973,15 +938,6 @@ export class DataProvider { return []; } } - - /** - * @param {House[]} houses - * @returns {FeatureCollection} - */ - static housesToCollection(houses) { - const features = houses.map((house) => house.toFeature()); - return new FeatureCollection(features); - } } export class ScoringEngine { |
