Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cityinfra/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
HELUSERS_ADGROUPS_CLAIM=(str, "groups"),
LOGGING_AUTH_DEBUG=(bool, False),
BASEMAP_SOURCE_URL=(str, "https://kartta.hel.fi/ws/geoserver/avoindata/wms"),
ADDRESS_SEARCH_BASE_URL=(str, "https://api.hel.fi/servicemap/v2/search"),
STATIC_URL=(str, "/static/"),
MEDIA_URL=(str, "/media/"),
CSRF_COOKIE_SECURE=(bool, True),
Expand Down Expand Up @@ -377,6 +378,7 @@
SRID_BOUNDARIES = {3879: (25487917.144, 6645439.071, 25514074.175, 6687278.623)}

BASEMAP_SOURCE_URL = env.str("BASEMAP_SOURCE_URL")
ADDRESS_SEARCH_BASE_URL = env.str("ADDRESS_SEARCH_BASE_URL")

# Import / Export
IMPORT_EXPORT_USE_TRANSACTIONS = True
Expand Down
1 change: 1 addition & 0 deletions map-view/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"i18next-browser-languagedetector": "^6.0.1",
"ol": "^10.5.0",
"prettier": "^3.5.3",
"proj4": "^2.19.10",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-i18next": "^15.3.1",
Expand Down
71 changes: 68 additions & 3 deletions map-view/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Fab from "@mui/material/Fab";
import { Theme } from "@mui/material/styles";
import { createStyles, withStyles, WithStyles } from "@mui/styles";
import LayersIcon from "@mui/icons-material/Layers";
import SearchIcon from "@mui/icons-material/Search";
import "ol/ol.css";
import React from "react";
import MapConfigAPI from "./api/MapConfigAPI";
Expand All @@ -12,17 +13,25 @@ import FeatureInfo from "./components/FeatureInfo";
import Map from "./common/Map";
import { Feature, MapConfig } from "./models";
import OngoingFetchInfo from "./components/OngoingFetchInfo";
import AddressInput from "./components/AddressInput";
import { Address, convertToEPSG3879OL, getAddressSearchResults, getNameFromAddress } from "./common/AddressSearchUtils";

const drawWidth = "400px";

const styles = (theme: Theme) =>
createStyles({
mapButton: {
layerSwitcherButton: {
position: "absolute",
right: "16px",
top: "16px",
color: "white",
},
searchButton: {
position: "absolute",
left: "50px",
top: "16px",
color: "white",
},
drawer: {
width: drawWidth,
},
Expand All @@ -35,9 +44,11 @@ interface AppProps extends WithStyles<typeof styles> {}

interface AppState {
open: boolean;
openAddressSearch: boolean;
mapConfig: MapConfig | null;
features: Feature[];
ongoingFeatureFetches: Set<string>;
addressSearchResults: Address[];
}

class App extends React.Component<AppProps, AppState> {
Expand All @@ -47,9 +58,11 @@ class App extends React.Component<AppProps, AppState> {
super(props);
this.state = {
open: false,
openAddressSearch: false,
mapConfig: null,
features: [],
ongoingFeatureFetches: new Set<string>(),
addressSearchResults: [],
};
}

Expand All @@ -66,9 +79,30 @@ class App extends React.Component<AppProps, AppState> {
});
}

handleSearch = async (address: string) => {
const addressSearchResults = await getAddressSearchResults(Map.getAddressSearchUrl(address));
this.setState({ addressSearchResults });
};

clearSearchResults = () => {
this.setState({ addressSearchResults: [] });
};

handleSelect = (result: Address) => {
if (result?.location?.coordinates) {
const coordinates = convertToEPSG3879OL(result.location.coordinates);
Map.clearSelectedAddressLayer();
Map.markSelectedAddress(coordinates, getNameFromAddress(result) || "Address name not found");
Map.centerToCoordinates(coordinates);
} else {
console.error("No valid coordinates found for selected address.");
}
this.setState({ addressSearchResults: [] });
};

render() {
const { classes } = this.props;
const { open, mapConfig, features, ongoingFeatureFetches } = this.state;
const { open, openAddressSearch, mapConfig, features, ongoingFeatureFetches, addressSearchResults } = this.state;
return (
<React.StrictMode>
<div className="App">
Expand All @@ -86,14 +120,45 @@ class App extends React.Component<AppProps, AppState> {
}}
/>
)}
<Fab
size="medium"
color="primary"
onClick={() => {
Map.showSelectedAddressLayer(!openAddressSearch);
this.setState({ openAddressSearch: !openAddressSearch });
}}
className={classes.searchButton}
>
<SearchIcon />
</Fab>
<Drawer
className={classes.drawer}
variant="persistent"
anchor="left"
open={openAddressSearch}
classes={{
paper: classes.drawerPaper,
}}
>
<AddressInput
onClose={() => {
Map.showSelectedAddressLayer(false);
this.setState({ openAddressSearch: false });
}}
onSearch={this.handleSearch}
onSelect={this.handleSelect}
clearResults={this.clearSearchResults}
results={addressSearchResults}
/>
</Drawer>
{ongoingFeatureFetches.size > 0 && (
<OngoingFetchInfo layerIdentifiers={ongoingFeatureFetches}></OngoingFetchInfo>
)}
<Fab
size="medium"
color="primary"
onClick={() => this.setState({ open: !open })}
className={classes.mapButton}
className={classes.layerSwitcherButton}
>
<LayersIcon />
</Fab>
Expand Down
1 change: 1 addition & 0 deletions map-view/src/api/__mocks__/mock-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export const mockMapConfig: MapConfig = {
imageUrl: "testurl",
},
traffic_sign_icons_url: "http://127.0.0.1:8000/static/traffic_control/svg/traffic_sign_icons/",
address_search_base_url: "http://127.0.0.1:8000/foo",
featureTypeEditNameMapping: {},
icon_scale: 0.1,
icon_type: "svg",
Expand Down
184 changes: 184 additions & 0 deletions map-view/src/common/AddressSearchUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { fromLonLat, addCoordinateTransforms, get as getProjection, addProjection, Projection } from "ol/proj";
import proj4 from "proj4";

// Define the EPSG:3879 projection string using proj4's definition function.
// This tells proj4 what the projection is. This must be done before anything else.
proj4.defs(
"EPSG:3879",
"+proj=tmerc +lat_0=0 +lon_0=25 +k=1 +x_0=25500000 +y_0=0 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs",
);

// Register the proj4 projection with OpenLayers.
addProjection(
new Projection({
code: "EPSG:3879",
units: "m",
}),
);

// Get the projection objects for both the source (WGS 84) and destination (EPSG:3879).
const wgs84Projection = getProjection("EPSG:4326");
const epsg3879Projection = getProjection("EPSG:3879");

// Check that both projections exist before adding the transform.
if (wgs84Projection && epsg3879Projection) {
// Add the coordinate transformation between EPSG:4326 and EPSG:3879.
addCoordinateTransforms(
wgs84Projection,
epsg3879Projection,
// Forward transform from EPSG:4326 to EPSG:3879
function (coordinate) {
return proj4("EPSG:4326", "EPSG:3879", coordinate);
},
// Inverse transform from EPSG:3879 to EPSG:4326
function (coordinate) {
return proj4("EPSG:3879", "EPSG:4326", coordinate);
},
);
} else {
console.error("Failed to get one or both projection objects for coordinate transformation.");
}

/**
*
*/
export function buildAddressSearchQuery(address: string) {
return new URLSearchParams({
q: address,
type: "address",
municipality: "helsinki",
language: getResolvedLanguageCode() || "fi",
}).toString();
}

interface Location {
type: string;
coordinates: [number, number];
}

interface Municipality {
id: string;
name: {
fi: string;
sv: string;
};
}

interface Street {
name: {
fi: string;
sv: string;
};
}

export interface Address {
object_type: string;
name: {
fi: string;
sv: string;
en: string;
};
number: string;
number_end: string;
letter: string;
modified_at: string;
municipality: Municipality;
street: Street;
location: Location;
}

interface ApiResponse {
count: number;
next: string | null;
previous: string | null;
results: Address[];
}

/**
* Transforms geographic coordinates from WGS 84 (EPSG:4326) to EPSG:3879
* using the OpenLayers fromLonLat function.
* * @param {number[]} wgs84Coords - The coordinates in WGS 84 format [longitude, latitude].
* @returns {number[]} The transformed coordinates in EPSG:3879 format [x, y], only 1 page of results are used.
*/
export function convertToEPSG3879OL(wgs84Coords: [number, number]): [number, number] {
const transformed = fromLonLat(wgs84Coords, "EPSG:3879");
return transformed as [number, number];
}

export async function getAddressSearchResults(url: string): Promise<Address[]> {
console.log("Fetching results from:", url);
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ApiResponse = await response.json();
return data.results || [];
} catch (error) {
console.error("Error fetching search results:", error);
return [];
}
}

/**
* Extracts the language code from the URL path.
* Assumes the language code is the first segment after the domain (e.g., http://localhost:8000/en/map/).
*
* @returns {string | undefined} The language code found in the URL, or undefined if not present.
*/
export function getLanguageCodeFromUrl(): string | undefined {
const pathSegments = window.location.pathname.split("/");
// The first segment after the root '/' is at index 1.
const langCode = pathSegments[1];
return langCode;
}

/**
* Resolves the language code by first checking the URL and then falling back to the browser settings.
*
* @returns {string | undefined} The resolved language code, or undefined if none is found.
*/
export function getResolvedLanguageCode(): string | undefined {
const urlLang = getLanguageCodeFromUrl();
if (urlLang) {
return urlLang;
}

const browserLang = getBrowserLanguageCode();
return browserLang;
}

/**
* Extracts the primary language code from the user's browser settings.
* For example, it will return 'en' from 'en-US'.
*
* @returns {string | undefined} The language code from the browser, or undefined if not available.
*/
export function getBrowserLanguageCode(): string | undefined {
// navigator.language returns a string like 'en-US' or 'fi-FI'
// .split('-')[0] extracts the primary language code (e.g., 'en' or 'fi')
return navigator.language.split("-")[0];
}

/**
* Gets the localized name from an Address object based on the resolved language code.
*
* @param {Address} address - The Address object containing the name variants.
* @returns {string | undefined} The name in the resolved language, or undefined if no match is found.
*/
export function getNameFromAddress(address: Address): string | undefined {
const { name } = address;

// 1. Get the resolved language code
const resolvedLang = getResolvedLanguageCode();
if (resolvedLang && name[resolvedLang as keyof typeof name]) {
return name[resolvedLang as keyof typeof name];
}

// 2. Optional: Fallback to a default language if no match is found
if (name.en) {
return name.en;
}

return undefined;
}
Loading
Loading