aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorPetri Hienonen <petri.hienonen@gmail.com>2025-11-13 18:12:17 +0200
committerPetri Hienonen <petri.hienonen@gmail.com>2025-11-13 18:12:17 +0200
commite4fdd8457d2d320eea502f0801fc22eceb8947b1 (patch)
tree110530c498b276085bb409a537a3e2174d53d435
parent2113f8269423932fa76ae4f822f77a07dd703266 (diff)
downloadhousing-e4fdd8457d2d320eea502f0801fc22eceb8947b1.tar.zst
Nothing
-rw-r--r--README.adoc2
-rw-r--r--app/components.js57
-rw-r--r--app/dom.js14
-rw-r--r--app/geometry.js56
-rw-r--r--app/main.js47
-rw-r--r--app/map.js34
-rw-r--r--app/models.js78
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;
}
diff --git a/app/dom.js b/app/dom.js
index 4419205..8e73a09 100644
--- a/app/dom.js
+++ b/app/dom.js
@@ -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;
}
}
diff --git a/app/map.js b/app/map.js
index 2c95fb9..5e69d4e 100644
--- a/app/map.js
+++ b/app/map.js
@@ -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 {