Add map functionality components

This commit is contained in:
Thomas Fuhrmann 2023-09-28 13:27:46 +02:00
parent 6ab4bb6daa
commit c8046c39f1
649 changed files with 21412 additions and 727 deletions

View file

@ -0,0 +1,150 @@
import { useRef, useEffect, RefObject } from 'react';
import { useTranslation } from 'react-i18next';
import initI18n from './i18n';
import MapView from '@arcgis/core/views/MapView';
import Basemap from '@arcgis/core/Basemap';
import esriRequest from '@arcgis/core/request';
import BaseTileLayer from '@arcgis/core/layers/BaseTileLayer';
import BasemapGallery from '@arcgis/core/widgets/BasemapGallery';
import VectorTileLayer from '@arcgis/core/layers/VectorTileLayer';
import TileLayer from '@arcgis/core/layers/TileLayer';
const BEVURL = 'https://maps.bev.gv.at/tiles/{z}/{x}/{y}.png';
// load localized strings
const match = location.pathname.match(/\/(\w+)/);
if (match && match.length > 1) {
const locale = match[1];
initI18n(locale);
}
export default function Basemaps({ view }: { view: MapView }) {
const basemapGalleryContainer = useRef<HTMLDivElement | null>(null);
const rendered = useRef<boolean>(false);
const { t } = useTranslation();
const BEVTileLayer = (BaseTileLayer as any).createSubclass({
// define instance properties
properties: {
urlTemplate: null,
},
// generate the tile url for a given level, row and column
getTileUrl: function (level: number, row: number, col: number) {
const z = level.toString().padStart(2, '0');
let x = row.toString().padStart(9, '0');
let y = col.toString().padStart(9, '0');
// this is the BEV tiling scheme
x = x.substring(0, 3) + '/' + x.substring(3, 3) + '/' + x.substring(6, 3);
y = y.substring(0, 3) + '/' + y.substring(3, 3) + '/' + y.substring(6, 3);
return this.urlTemplate.replace(/\{z\}/g, z).replace(/\{x\}/g, y).replace(/\{y\}/g, x);
},
// This method fetches tiles for the specified level and size.
fetchTile: function (level: number, row: number, col: number, options: any) {
// get the url for this tile
const url = this.getTileUrl(level, row, col);
// request for tiles based on the generated url
// the signal option ensures that obsolete requests are aborted
return esriRequest(url, {
responseType: 'image',
}).then(
function (this: any, response: any) {
// when esri request resolves successfully
// get the image from the response
const image = response.data;
const width = this.tileInfo.size[0];
const height = this.tileInfo.size[0];
// create a canvas with 2D rendering context
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
canvas.width = width;
canvas.height = height;
// Draw the blended image onto the canvas.
context?.drawImage(image, 0, 0, width, height);
const imageData = context?.getImageData(0, 0, canvas.width, canvas.height);
if (imageData) context?.putImageData(imageData, 0, 0);
return canvas;
}.bind(this)
);
},
copyright: t('basemaps.copyright-bev'),
});
useEffect(() => {
if (rendered.current) return;
rendered.current = true;
if (view) {
const topoLayer = new BEVTileLayer({
urlTemplate: BEVURL,
title: 'BEV-Topografie',
});
const basemapBEV = new Basemap({
baseLayers: [topoLayer],
title: t('basemaps.basemap-bev-title'),
id: 'basemap',
thumbnailUrl:
'https://gis.geosphere.at/portal/sharing/rest/content/items/1a94736328a1458abe435d23508f1822/data',
});
const basemapAT = new Basemap({
portalItem: {
id: 'df7d6cc9be754db8970614d2ee661f57',
portal: {
url: 'https://gis.geosphere.at/portal',
},
},
});
const basemapOSM = new Basemap({
portalItem: {
id: '5f6efd84434842fb9dcdcf6b9116dcd9',
portal: { url: 'https://gis.geosphere.at/portal' },
},
});
const lightgrayBase = new VectorTileLayer({
url: 'https://gis.geosphere.at/portal/sharing/rest/content/items/291da5eab3a0412593b66d384379f89f/resources/styles/root.json',
opacity: 0.5,
});
const lightGrayReference = new VectorTileLayer({
url: 'https://gis.geosphere.at/portal/sharing/rest/content/items/1768e8369a214dfab4e2167d5c5f2454/resources/styles/root.json',
opacity: 1,
});
const worldHillshade = new TileLayer({
url: 'https://services.arcgisonline.com/arcgis/rest/services/Elevation/World_Hillshade/MapServer',
});
const basemapEsri = new Basemap({
baseLayers: [worldHillshade, lightgrayBase, lightGrayReference],
title: t('basemaps.basemap-esri-title'),
thumbnailUrl:
'https://gis.geosphere.at/portal/sharing/rest/content/items/3eb1510943be4f29ae01c01ce229d8ba/data',
});
const basemapGalleryDiv = document.createElement('div');
basemapGalleryContainer.current?.append(basemapGalleryDiv);
new BasemapGallery({
container: basemapGalleryDiv,
view: view,
source: [basemapAT, basemapBEV, basemapOSM, basemapEsri],
});
}
return () => {};
});
return <div ref={basemapGalleryContainer}></div>;
}

View file

@ -1,20 +1,44 @@
{
"heading": "PDF drucken",
"titleLabel": "Titel",
"scaleLabel": "Maßstab",
"formatLabel": "Format",
"button": {
"print": "Drucken",
"cancel": "Abbrechen"
"basemaps": {
"title": "Grundkarten",
"copyright-bev": "© BEV, Bundesamt für Eich- und Vermessungswesen",
"basemap-esri-title": "Grau mit Geländeschummerung",
"basemap-bev-title": "BEV Topografie"
},
"formats": {
"A4 Hochformat": "A4 Hochformat",
"A4 Hochformat mit Legende": "A4 Hochformat mit Legende",
"A4 Querformat": "A4 Querformat",
"A4 Querformat mit Legende": "A4 Querformat mit Legende",
"A3 Hochformat": "A3 Hochformat",
"A3 Hochformat mit Legende": "A3 Hochformat mit Legende",
"A3 Querformat": "A3 Querformat",
"A3 Querformat mit Legende": "A3 Querformat mit Legende"
"search": {
"placeholder": "Orts- oder Adresssuche",
"popup-title": "Relevante Daten"
},
"legend": {
"title": "Legende"
},
"print": {
"heading": "Drucken",
"sub-heading": "PDF drucken",
"titleLabel": "Titel",
"scaleLabel": "Maßstab",
"formatLabel": "Format",
"button": {
"print": "Drucken",
"cancel": "Abbrechen"
},
"formats": {
"A4 Hochformat": "A4 Hochformat",
"A4 Hochformat mit Legende": "A4 Hochformat mit Legende",
"A4 Querformat": "A4 Querformat",
"A4 Querformat mit Legende": "A4 Querformat mit Legende",
"A3 Hochformat": "A3 Hochformat",
"A3 Hochformat mit Legende": "A3 Hochformat mit Legende",
"A3 Querformat": "A3 Querformat",
"A3 Querformat mit Legende": "A3 Querformat mit Legende"
}
},
"layers": {
"title": "Layer",
"increaseOpacity": "Deckkraft erhöhen",
"decreaseOpacity": "Deckkraft verringern",
"table": {
"close": "Schließen"
}
}
}

View file

@ -1,20 +1,44 @@
{
"heading": "Print PDF",
"titleLabel": "Title",
"scaleLabel": "Scale",
"formatLabel": "Format",
"button": {
"print": "Print",
"cancel": "Cancel"
"basemaps": {
"title": "Basemaps",
"copyright-bev": "© BEV, Federal Office of Metrology and Surveying",
"basemap-esri-title": "Gray with hillshade",
"basemap-bev-title": "BEV topography"
},
"formats": {
"A4 Hochformat": "A4 portrait",
"A4 Hochformat mit Legende": "A4 portrait with legend",
"A4 Querformat": "A4 landscape",
"A4 Querformat mit Legende": "A4 landscape with legend",
"A3 Hochformat": "A3 portrait",
"A3 Hochformat mit Legende": "A3 portrait with legend",
"A3 Querformat": "A3 landscape",
"A3 Querformat mit Legende": "A3 landscape with legend"
"search": {
"placeholder": "Search for place or address",
"popup-title": "Relevant data"
},
"print": {
"heading": "Print",
"sub-heading": "Print PDF",
"titleLabel": "Title",
"scaleLabel": "Scale",
"formatLabel": "Format",
"button": {
"print": "Print",
"cancel": "Cancel"
},
"formats": {
"A4 Hochformat": "A4 portrait",
"A4 Hochformat mit Legende": "A4 portrait with legend",
"A4 Querformat": "A4 landscape",
"A4 Querformat mit Legende": "A4 landscape with legend",
"A3 Hochformat": "A3 portrait",
"A3 Hochformat mit Legende": "A3 portrait with legend",
"A3 Querformat": "A3 landscape",
"A3 Querformat mit Legende": "A3 landscape with legend"
}
},
"layers": {
"title": "Layers",
"increaseOpacity": "Increase opacity",
"decreaseOpacity": "Decrease opacity",
"table": {
"close": "Close"
}
},
"legend": {
"title": "Legend"
}
}

155
app/[locale]/layer-list.tsx Normal file
View file

@ -0,0 +1,155 @@
import { useRef, useEffect, RefObject } from 'react';
import { useTranslation } from 'react-i18next';
import initI18n from './i18n';
import MapView from '@arcgis/core/views/MapView';
import LayerList from '@arcgis/core/widgets/LayerList';
import FeatureTable from '@arcgis/core/widgets/FeatureTable';
import ImageryTileLayer from '@arcgis/core/layers/ImageryTileLayer';
import MapImageLayer from '@arcgis/core/layers/MapImageLayer';
import Sublayer from '@arcgis/core/layers/support/Sublayer';
import ButtonMenuItem from '@arcgis/core/widgets/FeatureTable/Grid/support/ButtonMenuItem';
// load localized strings
const match = location.pathname.match(/\/(\w+)/);
if (match && match.length > 1) {
const locale = match[1];
initI18n(locale);
}
export default function Layers({ view, tableRoot }: { view: MapView; tableRoot: RefObject<HTMLDivElement> }) {
const htmlDiv = useRef<HTMLDivElement>(null);
const featureTable = useRef<FeatureTable | null>(null);
const rendered = useRef<boolean>(false);
const { t } = useTranslation();
useEffect(() => {
if (rendered.current) return;
rendered.current = true;
const createTable = async (layer: Sublayer, view: MapView) => {
const tableContainer = document.createElement('div');
tableContainer.className = 'h-full w-full';
if (tableRoot && tableRoot.current) {
tableRoot.current.classList.remove('hidden');
tableRoot.current.append(tableContainer);
}
const featureLayer = await layer.createFeatureLayer();
featureTable.current = new FeatureTable({
view: view,
layer: featureLayer,
container: tableContainer,
menuConfig: {
items: [
{
label: t('layers.table.close'),
iconClass: 'esri-icon-close',
clickFunction: function () {
featureTable.current?.destroy();
if (tableRoot && tableRoot.current) {
tableRoot.current.classList.add('hidden');
}
},
} as unknown as ButtonMenuItem,
],
},
});
};
if (htmlDiv.current) {
const arcGISAPIWidgetContainer = document.createElement('div');
htmlDiv.current.append(arcGISAPIWidgetContainer);
const layerList = new LayerList({
view,
container: htmlDiv.current,
selectionEnabled: true,
listItemCreatedFunction: async function (event) {
const item = event.item;
const type = item.layer.type;
if (type === 'imagery-tile' || type === 'map-image') {
item.actionsSections = [
[
{
title: 'Info',
className: 'esri-icon-description',
id: 'info',
},
],
[
{
title: t('layers.increaseOpacity'),
className: 'esri-icon-up',
id: 'increase-opacity',
},
{
title: t('layers.decreaseOpacity'),
className: 'esri-icon-down',
id: 'decrease-opacity',
},
],
];
}
if (item.layer.declaredClass === 'esri.layers.support.Sublayer') {
item.actionsSections = [
[
{
title: 'Table',
className: 'esri-icon-table',
id: 'table',
},
],
];
}
},
});
layerList.on('trigger-action', async (event) => {
const id = event.action.id;
const item = event.item;
const layer = item.layer;
const type = layer.type;
if (id === 'info') {
if (type === 'imagery-tile') {
window.open((layer as ImageryTileLayer).portalItem?.itemPageUrl);
}
if (type === 'map-image') {
window.open((layer as MapImageLayer).portalItem?.itemPageUrl);
}
}
if (id === 'increase-opacity') {
if (layer.opacity < 1) {
layer.opacity += 0.25;
}
}
if (id === 'decrease-opacity') {
if (layer.opacity > 0) {
layer.opacity -= 0.25;
}
}
if (id === 'table') {
featureTable.current?.destroy();
try {
createTable(layer as unknown as Sublayer, view);
} catch (error) {}
}
});
}
return () => {};
});
return <div ref={htmlDiv}></div>;
}

View file

@ -1,121 +1,278 @@
import { useRef, useEffect, lazy } from 'react';
import { createRoot } from 'react-dom/client';
import { useRef, useState, useEffect, lazy } from 'react';
import { Root, createRoot } from 'react-dom/client';
import '@esri/calcite-components/dist/calcite/calcite.css';
import { useTranslation } from 'react-i18next';
import initI18n from './i18n';
import MapView from '@arcgis/core/views/MapView.js';
import WebMap from '@arcgis/core/WebMap.js';
import esriConfig from '@arcgis/core/config.js';
import LayerList from '@arcgis/core/widgets/LayerList';
import Expand from '@arcgis/core/widgets/Expand';
import ScaleBar from '@arcgis/core/widgets/ScaleBar.js';
import MapView from '@arcgis/core/views/MapView';
import WebMap from '@arcgis/core/WebMap';
import esriConfig from '@arcgis/core/config';
import ScaleBar from '@arcgis/core/widgets/ScaleBar';
import Legend from '@arcgis/core/widgets/Legend';
import WMTSLayer from '@arcgis/core/layers/WMTSLayer';
import VectorTileLayer from '@arcgis/core/layers/VectorTileLayer';
import TileLayer from '@arcgis/core/layers/TileLayer';
import Map from '@arcgis/core/Map.js';
import Basemap from '@arcgis/core/Basemap';
import * as reactiveUtils from '@arcgis/core/core/reactiveUtils.js';
import * as intl from '@arcgis/core/intl.js';
import * as intl from '@arcgis/core/intl';
// set asset path for ArcGIS Maps SDK widgets
esriConfig.assetsPath = './assets';
let mapView: MapView;
const webMapID = '7d0768f73d3e4be2b32c22274c600cb3';
// ids of web map items in portal
const webMapDEID = '7d0768f73d3e4be2b32c22274c600cb3';
const webMapENID = 'dbf5532d06954c6a989d4f022de83f70';
// lazy load Print component
// lazy load print component
const Print = lazy(() => import('./print'));
const Layers = lazy(() => import('./layer-list'));
const Basemaps = lazy(() => import('./basemap-list'));
const Search = lazy(() => import('./search'));
// import Calcite components
import '@esri/calcite-components/dist/calcite/calcite.css';
import { setAssetPath } from '@esri/calcite-components/dist/components';
setAssetPath(window.location.href);
import '@esri/calcite-components/dist/components/calcite-shell';
import '@esri/calcite-components/dist/components/calcite-shell-panel';
import '@esri/calcite-components/dist/components/calcite-shell-center-row';
import '@esri/calcite-components/dist/components/calcite-action-bar';
import '@esri/calcite-components/dist/components/calcite-action';
import '@esri/calcite-components/dist/components/calcite-panel';
import {
CalciteShell,
CalciteShellPanel,
CalciteActionBar,
CalciteAction,
CalcitePanel,
} from '@esri/calcite-components-react';
// load localized strings
const match = location.pathname.match(/\/(\w+)/);
if (match && match.length > 1) {
const locale = match[1];
initI18n(locale);
}
export default function MapComponent({ locale }: { locale: string }) {
const printRoot = useRef<any>(null);
const mapRef = useRef(null);
const maskRef = useRef<HTMLDivElement>(null);
const printRoot = useRef<Root | null>(null);
const maskRoot = useRef<HTMLDivElement>(null);
const tableRoot = useRef<HTMLDivElement>(null);
const mapView = useRef<MapView | null>(null);
const previousId = useRef<string | null>(null);
const [actionBarExpanded, setActionBarExpanded] = useState<boolean>(false);
const { t } = useTranslation();
useEffect(() => {
if (mapRef.current) {
if (!mapView.current) {
// set locale for ArcGIS Maps SDK widgets
intl.setLocale(locale);
const webMap = new WebMap({
portalItem: {
id: webMapID,
id: locale === 'de' ? webMapDEID : webMapENID,
portal: {
url: 'https://gis.geosphere.at/portal',
},
},
});
const wmtsLayer = new WMTSLayer({
url: 'https://mapsneu.wien.gv.at/basemapneu',
});
// const wmtsLayer = new WMTSLayer({
// url: 'https://mapsneu.wien.gv.at/basemapneu',
// });
const basemap = new Basemap({
baseLayers: [wmtsLayer],
// const basemap = new Basemap({
// baseLayers: [wmtsLayer],
// });
const lightgrayBase = new VectorTileLayer({
url: 'https://gis.geosphere.at/portal/sharing/rest/content/items/291da5eab3a0412593b66d384379f89f/resources/styles/root.json',
opacity: 0.5,
});
const lightGrayReference = new VectorTileLayer({
url: 'https://gis.geosphere.at/portal/sharing/rest/content/items/1768e8369a214dfab4e2167d5c5f2454/resources/styles/root.json',
opacity: 1,
});
const worldHillshade = new TileLayer({
url: 'https://services.arcgisonline.com/arcgis/rest/services/Elevation/World_Hillshade/MapServer',
});
const basemapEsri = new Basemap({
baseLayers: [worldHillshade, lightgrayBase, lightGrayReference],
title: 'Esri',
thumbnailUrl:
'https://gis.geosphere.at/portal/sharing/rest/content/items/3eb1510943be4f29ae01c01ce229d8ba/data',
});
const map = new Map({
basemap: basemap,
basemap: basemapEsri,
});
const mapView = new MapView({
container: mapRef.current,
const view = new MapView({
container: 'map-container',
map: map,
padding: {
left: 49,
},
popup: {
dockOptions: {
position: 'auto',
breakpoint: {
width: 5000,
},
},
},
extent: {
ymax: 6424330,
xmin: 923200,
xmax: 2017806,
ymin: 5616270,
spatialReference: {
wkid: 3857,
},
},
});
mapView.current = view;
view.ui.empty('top-left');
webMap.load().then(() => {
map.layers = webMap.layers;
createRoot(document.createElement('div')).render(<Search view={view}></Search>);
});
const layerList = new LayerList({
view: mapView,
});
const layerListExpand = new Expand({
content: layerList,
});
const container = document.createElement('div');
const printExpand = new Expand({
content: container,
expandIcon: 'print',
label: 'Print',
});
reactiveUtils.watch(
() => printExpand.expanded,
(expanded) => {
if (expanded) {
printRoot.current = createRoot(container);
printRoot.current.render(<Print view={mapView} mask={maskRef}></Print>);
} else {
maskRef.current?.classList.add('hidden');
printRoot.current.unmount();
}
}
);
const scaleBar = new ScaleBar({
view: mapView,
view: view,
unit: 'metric',
});
mapView.ui.add([layerListExpand, printExpand], 'top-right');
mapView.ui.add([scaleBar], 'bottom-left');
view.ui.add([scaleBar], 'bottom-left');
new Legend({
view: view,
container: 'legend-container',
});
}
return () => {
mapView.destroy();
if (printRoot.current) {
printRoot.current.unmount();
return () => {};
}, [locale]);
const handleCalciteActionBarToggle = () => {
setActionBarExpanded(!actionBarExpanded);
if (mapView.current) {
mapView.current.padding = !actionBarExpanded ? { left: 150 } : { left: 49 };
}
if (tableRoot.current) {
if (!actionBarExpanded) {
tableRoot.current.classList.add('left-40');
tableRoot.current.classList.remove('left-14');
} else {
tableRoot.current.classList.add('left-14');
tableRoot.current.classList.remove('left-40');
}
};
}, [mapRef, maskRef]);
}
};
const handleClick = (event: any) => {
if (event.target.tagName !== 'CALCITE-ACTION') {
return;
}
const nextId = event.target.dataset.actionId;
if (previousId.current) {
const previousPanel = document.querySelector(`[data-panel-id=${previousId.current}]`) as HTMLCalcitePanelElement;
if (previousPanel) {
previousPanel.hidden = true;
}
if (previousId.current === 'print') {
maskRoot.current?.classList.add('hidden');
}
}
const nextPanel = document.querySelector(`[data-panel-id=${nextId}]`) as HTMLCalcitePanelElement;
if (nextPanel && nextId !== previousId.current) {
nextPanel.hidden = false;
previousId.current = nextId;
if (nextId === 'print') {
maskRoot.current?.classList.remove('hidden');
}
} else {
previousId.current = null;
}
};
return (
<div className="h-screen overflow-hidden">
<div ref={mapRef} className="h-screen"></div>
<div
ref={maskRef}
className="hidden absolute bg-red-300 border-2 border-red-600 pointer-events-none opacity-50"
></div>
<div>
<CalciteShell contentBehind>
<h2 id="header-title" slot="header"></h2>
<CalciteShellPanel slot="panel-start" displayMode="float">
<CalciteActionBar
slot="action-bar"
onCalciteActionBarToggle={handleCalciteActionBarToggle}
className="border-r border-r-gray-400"
>
<CalciteAction
data-action-id="layers"
icon="layers"
text={t('layers.title')}
onClick={handleClick}
></CalciteAction>
<CalciteAction
data-action-id="basemaps"
icon="basemap"
text={t('basemaps.title')}
onClick={handleClick}
></CalciteAction>
<CalciteAction
data-action-id="legend"
icon="legend"
text={t('legend.title')}
onClick={handleClick}
></CalciteAction>
<CalciteAction
data-action-id="print"
icon="print"
text={t('print.heading')}
onClick={handleClick}
></CalciteAction>
<CalciteAction data-action-id="info" icon="information" text="Info" onClick={handleClick}></CalciteAction>
</CalciteActionBar>
<CalcitePanel data-panel-id="layers" heading={t('layers.title')} hidden>
{mapView.current && <Layers view={mapView.current} tableRoot={tableRoot}></Layers>}
</CalcitePanel>
<CalcitePanel data-panel-id="basemaps" heading={t('basemaps.title')} hidden>
{mapView.current && <Basemaps view={mapView.current}></Basemaps>}
</CalcitePanel>
<CalcitePanel data-panel-id="legend" heading={t('legend.title')} hidden>
<div id="legend-container"></div>
</CalcitePanel>
<CalcitePanel data-panel-id="print" heading={t('print.heading')} hidden>
{mapView.current && <Print view={mapView.current} maskRoot={maskRoot}></Print>}
</CalcitePanel>
<CalcitePanel data-panel-id="info" heading="Info" hidden>
<div id="info-container"></div>
</CalcitePanel>
</CalciteShellPanel>
<div className="h-screen w-full" id="map-container">
<div ref={tableRoot} className="hidden absolute left-14 bottom-5 h-1/3 right-2 border border-gray-400"></div>
</div>
<div
ref={maskRoot}
className="hidden absolute bg-red-300 border-2 border-red-600 opacity-50 pointer-events-none"
></div>
</CalciteShell>
</div>
);
}

View file

@ -1,6 +1,6 @@
import { useRef, useState, useEffect, RefObject } from 'react';
import { useTranslation } from 'react-i18next';
import { useTranslation } from 'react-i18next';
import initI18n from './i18n';
import MapView from '@arcgis/core/views/MapView';
@ -71,11 +71,14 @@ if (match && match.length > 1) {
initI18n(locale);
}
export default function Print({ view, mask }: { view: MapView; mask: RefObject<HTMLDivElement> | null }) {
const [title, setTitle] = useState('GeoSphere Austria');
export default function Print({ view, maskRoot }: { view: MapView; maskRoot: RefObject<HTMLDivElement> }) {
const [title, setTitle] = useState<string>('GeoSphere Austria');
const [format, setFormat] = useState<string>(Object.keys(formats)[0]);
const [scale, setScale] = useState<number>(10000);
const [scale, setScale] = useState<number>(scales[0]);
const [printing, setPrinting] = useState<boolean>(false);
const rendered = useRef<boolean>(false);
const { t } = useTranslation();
const currentScale = useRef<number>();
@ -88,6 +91,7 @@ export default function Print({ view, mask }: { view: MapView; mask: RefObject<H
const handlePrint = () => {
setPrinting(true);
const template = new PrintTemplate({
layout: format,
format: 'pdf',
@ -97,7 +101,7 @@ export default function Print({ view, mask }: { view: MapView; mask: RefObject<H
customTextElements: [],
},
exportOptions: {
dpi: 98,
dpi: 96,
},
scalePreserved: true,
outScale: scale,
@ -157,40 +161,42 @@ export default function Print({ view, mask }: { view: MapView; mask: RefObject<H
setMask(width, height);
};
// set the mask for print preview
const setMask = (width: number, height: number) => {
const center = view.center;
const xmin = center.x - width / 2;
const xmax = center.x + width / 2;
const ymin = center.y - height / 2;
const ymax = center.y + height / 2;
if (center) {
const xmin = center.x - width / 2;
const xmax = center.x + width / 2;
const ymin = center.y - height / 2;
const ymax = center.y + height / 2;
const upperLeft = view.toScreen(
new Point({
x: xmin,
y: ymax,
spatialReference: view.spatialReference,
})
);
const upperLeft = view.toScreen(
new Point({
x: xmin,
y: ymax,
spatialReference: view.spatialReference,
})
);
const lowerRight = view.toScreen(
new Point({
x: xmax,
y: ymin,
spatialReference: view.spatialReference,
})
);
const lowerRight = view.toScreen(
new Point({
x: xmax,
y: ymin,
spatialReference: view.spatialReference,
})
);
const left = clamp(Math.round(upperLeft.x), 0, view.width);
const top = clamp(Math.round(upperLeft.y), 0, view.height);
const maskWidth = clamp(Math.round(lowerRight.x - upperLeft.x), 0, view.width);
const maskHeight = clamp(Math.round(lowerRight.y - upperLeft.y), 0, view.height);
const left = clamp(Math.round(upperLeft.x), 0, view.width);
const top = clamp(Math.round(upperLeft.y), 0, view.height);
const maskWidth = clamp(Math.round(lowerRight.x - upperLeft.x), 0, view.width);
const maskHeight = clamp(Math.round(lowerRight.y - upperLeft.y), 0, view.height);
if (mask && mask.current) {
mask.current.classList.remove('hidden');
mask.current.style.left = left + 'px';
mask.current.style.top = top + 'px';
mask.current.style.width = maskWidth + 'px';
mask.current.style.height = maskHeight + 'px';
if (maskRoot && maskRoot.current) {
maskRoot.current.style.left = left + 'px';
maskRoot.current.style.top = top + 'px';
maskRoot.current.style.width = maskWidth + 'px';
maskRoot.current.style.height = maskHeight + 'px';
}
}
};
@ -199,19 +205,23 @@ export default function Print({ view, mask }: { view: MapView; mask: RefObject<H
updatePreview(currentScale.current, currentFormat.current);
}
const handle = reactiveUtils.watch(
() => view?.extent,
() => {
if (currentScale.current && currentFormat.current) {
updatePreview(currentScale.current, currentFormat.current);
let handle: any;
if (!rendered.current) {
handle = reactiveUtils.watch(
() => view?.extent,
() => {
if (currentScale.current && currentFormat.current) {
updatePreview(currentScale.current, currentFormat.current);
}
}
}
);
);
}
rendered.current = true;
return () => {
handle.remove();
handle?.remove();
};
}, []);
});
const handleAbort = () => {
if (controllerRef.current) {
@ -221,23 +231,23 @@ export default function Print({ view, mask }: { view: MapView; mask: RefObject<H
};
return (
<CalcitePanel heading={t('heading')} className="px-3 w-80">
<CalcitePanel heading={t('print.sub-heading')} className="px-3">
<div>{printing && <CalciteProgress type="indeterminate"></CalciteProgress>}</div>
<CalciteLabel className="mt-5 mx-5">
{t('titleLabel')}
{t('print.titleLabel')}
<CalciteInputText
placeholder={t('titleLabel')}
placeholder={t('print.titleLabel')}
onCalciteInputTextInput={handleTitleChange}
className="mx-0"
></CalciteInputText>
</CalciteLabel>
<CalciteLabel className="mx-5">
{t('formatLabel')}
{t('print.formatLabel')}
<CalciteSelect label="format" onCalciteSelectChange={handleFormatChange}>
{Object.keys(formats).map((formatName) => {
return (
<CalciteOption key={`${formatName}`} value={`${formatName}`}>
{t(`formats.${formatName}`)}
{t(`print.formats.${formatName}`)}
</CalciteOption>
);
})}
@ -245,7 +255,7 @@ export default function Print({ view, mask }: { view: MapView; mask: RefObject<H
</CalciteLabel>
<CalciteLabel className="mx-5">
{t('scaleLabel')}
{t('print.scaleLabel')}
<CalciteSelect label="scale" onCalciteSelectChange={handleScaleChange}>
{scales.map((num) => (
<CalciteOption value={`${num}`} key={num}>{`1:${num
@ -258,12 +268,12 @@ export default function Print({ view, mask }: { view: MapView; mask: RefObject<H
{!printing ? (
<CalciteButton width="half" slot="footer" onClick={handlePrint}>
{t('button.print')}
{t('print.button.print')}
</CalciteButton>
) : (
<>
<CalciteButton width="half" slot="footer" onClick={handleAbort}>
{t('button.cancel')}
{t('print.button.cancel')}
</CalciteButton>
</>
)}

629
app/[locale]/search.tsx Normal file
View file

@ -0,0 +1,629 @@
import { useRef, useState, useEffect } from 'react';
import { Root, createRoot } from 'react-dom/client';
import { useTranslation } from 'react-i18next';
import initI18n from './i18n';
// import Calcite components
import '@esri/calcite-components/dist/calcite/calcite.css';
import { setAssetPath } from '@esri/calcite-components/dist/components';
setAssetPath(window.location.href);
import '@esri/calcite-components/dist/components/calcite-accordion';
import '@esri/calcite-components/dist/components/calcite-accordion-item';
import '@esri/calcite-components/dist/components/calcite-link';
import '@esri/calcite-components/dist/components/calcite-list';
import '@esri/calcite-components/dist/components/calcite-list-item';
import '@esri/calcite-components/dist/components/calcite-action';
import {
CalciteAccordion,
CalciteAccordionItem,
CalciteLink,
CalciteList,
CalciteListItem,
CalciteAction,
} from '@esri/calcite-components-react';
import MapView from '@arcgis/core/views/MapView';
import Search from '@arcgis/core/widgets/Search';
import FeatureLayer from '@arcgis/core/layers/FeatureLayer';
import Graphic from '@arcgis/core/Graphic';
import { eachAlways } from '@arcgis/core/core/promiseUtils.js';
import SearchSource from '@arcgis/core/widgets/Search/SearchSource';
import Point from '@arcgis/core/geometry/Point';
import Polyline from '@arcgis/core/geometry/Polyline';
import Polygon from '@arcgis/core/geometry/Polygon';
import { geodesicBuffer } from '@arcgis/core/geometry/geometryEngine';
import MapImageLayer from '@arcgis/core/layers/MapImageLayer';
import SimpleFillSymbol from '@arcgis/core/symbols/SimpleFillSymbol';
import SimpleMarkerSymbol from '@arcgis/core/symbols/SimpleMarkerSymbol';
import Sublayer from '@arcgis/core/layers/support/Sublayer';
import PopupTemplate from '@arcgis/core/PopupTemplate';
// create feaure layer from URL of data index layer
const datenIndexURL = 'https://gis.geosphere.at/maps/rest/services/datenindex/raster_5000/MapServer/0';
const indexLayer = new FeatureLayer({ url: datenIndexURL, title: 'Datenindex 1:5.000', opacity: 0 });
// custom type definitions
interface CustomLayer {
id: string;
type: string;
title: string;
visible: boolean;
}
interface CustomGroupLayer {
id: string;
type: string;
title: string;
visible: boolean;
layers: (CustomGroupLayer | CustomLayer)[];
}
interface LayerToFeaturesMap {
[key: string]: string[];
}
// load localized strings
const match = location.pathname.match(/\/(\w+)/);
if (match && match.length > 1) {
const locale = match[1];
initI18n(locale);
}
// custom React component
export default function SearchComponent({ view }: { view: MapView }) {
const [currentTarget, setCurrentTarget] = useState<Graphic | null>(null);
const rendered = useRef<boolean>(false);
const currentGraphicRef = useRef<Graphic | null>(null);
currentGraphicRef.current = currentTarget;
const { t } = useTranslation();
// get map image layer from sublayer
const getMapImageLayer = (layerURL: string): MapImageLayer | undefined => {
if (view) {
const filteredLayerViews = view.allLayerViews.filter((layerView) => {
const regex = /^https:\/\/.+\/MapServer/g;
const matches = layerURL.match(regex);
let mapImageLayerURL;
if (matches && matches.length > 0) mapImageLayerURL = matches[0];
if (layerView.layer.type === 'map-image') {
return (layerView.layer as MapImageLayer).url === mapImageLayerURL;
} else {
return false;
}
});
let mapImageLayer;
if (filteredLayerViews.length > 0) {
mapImageLayer = filteredLayerViews.at(0).layer as MapImageLayer;
}
return mapImageLayer;
}
};
// handle toggle layer visibility
const handleToggleVisibility = (event: any) => {
const layerItem = event.target;
const layerId = layerItem.getAttribute('text');
const icon = layerItem.getAttribute('icon');
if (layerId) {
const layer = view.map?.findLayerById(layerId);
if (icon === 'view-hide') {
layerItem.setAttribute('icon', 'view-visible');
if (layer) {
layer.visible = true;
}
} else {
layerItem.setAttribute('icon', 'view-hide');
if (layer) {
layer.visible = false;
}
}
}
};
// handle zoom to feature
const handleZoomTo = async (event: any) => {
const layerURL = event.target.getAttribute('text');
const res = await fetch(layerURL + '?f=json');
const json = await res.json();
const mapImageLayer = getMapImageLayer(layerURL);
const sr = mapImageLayer?.spatialReference;
const geometry = json.feature?.geometry;
if (geometry.x && geometry.y) {
view.goTo(
new Point({
x: geometry.x,
y: geometry.y,
spatialReference: sr,
})
);
return;
}
if (geometry.paths) {
view.goTo(
new Polyline({
paths: geometry.paths,
spatialReference: sr,
})
);
return;
}
if (geometry.rings) {
view.goTo(
new Polygon({
rings: geometry.rings,
spatialReference: sr,
})
);
return;
}
};
// remove layers from layer tree by given filter
const removeLayers = (layers: (CustomGroupLayer | CustomLayer)[], keepLayer: any): any => {
return layers
.filter((layer) => keepLayer(layer))
.map((layer) => {
if (layer.type === 'group' && (layer as CustomGroupLayer).layers) {
return { ...layer, layers: removeLayers((layer as CustomGroupLayer).layers, keepLayer) };
} else {
return layer;
}
});
};
// get custom layer objects from layer tree
const getLayerObjects = (layers: any) => {
return layers.map((layer: any) => {
if (layer.layers) {
return {
id: layer.id,
type: layer.type,
title: layer.title,
visible: layer.visible,
layers: getLayerObjects(layer.layers.toArray()),
};
} else {
return { id: layer.id, type: layer.type, title: layer.title, visible: layer.visible };
}
});
};
// build query string for custom search source (BEV geocoding service)
const buildQueryString = (searchTerm: string) => `?term=${encodeURI(searchTerm)}`;
const url = 'https://kataster.bev.gv.at/api/all4map';
const customSearchSource = new SearchSource({
placeholder: t('search.placeholder'),
// provide suggestions to the Search widget
getSuggestions: async (params) => {
const res = await fetch(url + buildQueryString(params.suggestTerm));
const json = await res.json();
if (json.data && json.data.features?.length > 0) {
return json.data.features.map((feature: any) => {
return {
key: 'bev',
text: feature.properties.name,
sourceIndex: params.sourceIndex,
};
});
}
},
// find results from suggestions
getResults: async (params) => {
const res = await fetch(url + buildQueryString(params.suggestResult.text));
const json = await res.json();
if (json.data && json.data.features?.length > 0) {
const searchResults = json.data.features.map((feature: any) => {
// create a graphic for the search widget
if (feature.geometry.type === 'Point') {
const graphic = new Graphic({
geometry: new Point({
x: feature.geometry.coordinates[0],
y: feature.geometry.coordinates[1],
spatialReference: {
wkid: 4326,
},
}),
symbol: {
type: 'simple-marker',
style: 'circle',
color: [51, 51, 204, 0.5],
size: '8px',
outline: {
color: 'white',
width: 1,
},
} as unknown as SimpleMarkerSymbol,
});
return {
extent: null,
feature: graphic,
target: new Graphic({
// create buffer for point geometries to allow zoom to
geometry: geodesicBuffer(graphic.geometry, 1000, 'meters') as Polygon,
}),
name: feature.properties.name,
};
} else {
const coords = feature.geometry.coordinates;
const graphic = new Graphic({
geometry: new Polygon({
rings: coords,
spatialReference: {
wkid: 4326,
},
}),
symbol: {
type: 'simple-fill',
color: [51, 51, 204, 0.5],
style: 'solid',
outline: {
color: 'white',
width: 1,
},
} as unknown as SimpleFillSymbol,
});
return {
extent: graphic.geometry.extent,
feature: graphic,
target: graphic,
name: feature.properties.name,
};
}
});
// return search results
return searchResults;
}
},
});
// create query from cellcode for 3x3 neighbourhood
const createQueryFromCellcode = (cellcode: string) => {
const { north, east }: any = cellcode.match(/N(?<north>\d+)E(?<east>\d+)/)?.groups;
const northNumber = parseInt(north);
const eastNumber = parseInt(east);
const operations = [
[1, -1],
[1, 0],
[1, 1],
[0, -1],
[0, 1],
[-1, -1],
[-1, 0],
[-1, 1],
];
const cellcodeQueries = operations.map(
(operation) => `cellcode = '10kmN${northNumber + operation[0]}E${eastNumber + operation[1]}'`
);
return `cellcode = '${cellcode}' OR ` + cellcodeQueries.join(' OR ');
};
useEffect(() => {
if (rendered.current) return;
rendered.current = true;
// add data index layer
view.map.layers.push(indexLayer);
// add search widget with custom search source
const search = new Search({
view: view,
popupEnabled: false,
sources: [customSearchSource],
includeDefaultSources: false,
});
view.ui.add(search, 'top-left');
// add event handler for select-result events
search.on('select-result', (event) => {
view.closePopup();
// get selected feature and display it on map
const graphic = event.result.feature;
if (graphic.geometry.type === 'point') {
setCurrentTarget(
new Graphic({
// create buffer for point geometries to allow zoom to
geometry: geodesicBuffer(graphic.geometry, 1000, 'meters') as Polygon,
})
);
} else {
setCurrentTarget(graphic);
}
view.graphics.removeAll();
view.graphics.add(graphic);
// query for intersecting features
indexLayer
.queryFeatures({
geometry: graphic.geometry,
spatialRelationship: 'intersects',
returnGeometry: false,
outFields: ['fid', 'cellcode'],
})
.then((featureSet) => {
if (featureSet.features.length === 0) {
return;
}
// if feature has an extent just return intersecting raster cells
// otherwise create a query including a 3x3 neighbourhood
if (event.result.extent) {
const objectIds = featureSet.features.map((feature) => feature.attributes['fid'] as number);
queryRelatedFeaturesFromOIds(objectIds, false);
} else {
const cellcode = featureSet.features[0].attributes['cellcode'];
const query = createQueryFromCellcode(cellcode);
indexLayer
.queryFeatures({
where: query,
returnGeometry: false,
outFields: ['fid'],
})
.then((resultSet) => {
const objectIds = resultSet.features.map((feature) => feature.attributes['fid']);
queryRelatedFeaturesFromOIds(objectIds, false);
});
}
});
});
// for each raster cell given by its OID and it each relationship class
// of the index layer - query related features
const queryRelatedFeaturesFromOIds = (objectIds: number[], isPopupContent: boolean) => {
const relatedFeaturesByLayer: LayerToFeaturesMap = {};
const promises = indexLayer.relationships.map((relationship) => {
return indexLayer
.queryRelatedFeatures({
outFields: ['url'],
relationshipId: relationship.id,
objectIds: objectIds,
})
.then((relatedFeatureSets) => {
Object.keys(relatedFeatureSets).forEach((objectId) => {
if (!relatedFeatureSets[objectId]) {
return;
}
const regex = /^https:\/\/.+\/MapServer\/\d+/g;
const relatedFeatures = relatedFeatureSets[objectId].features;
// get url of related feature layer
let layerURL: string = '';
if (relatedFeatures.length > 0) {
const matches = relatedFeatures[0].attributes.url.match(regex);
if (matches.length > 0) {
layerURL = matches[0];
}
}
const urls = relatedFeatures.map((feature: any) => feature.attributes.url);
if (relatedFeaturesByLayer[layerURL]) {
relatedFeaturesByLayer[layerURL] = relatedFeaturesByLayer[layerURL].concat(urls);
} else {
relatedFeaturesByLayer[layerURL] = urls;
}
});
});
});
// wait until all promises are fulfilled
return eachAlways(promises).then(() => {
const relatedFeatures: { [key: string]: JSX.Element[] } = {};
const mapImageLayers: MapImageLayer[] = [];
Object.keys(relatedFeaturesByLayer).forEach((layerURL) => {
const relatedFeatureURLs = [...new Set(relatedFeaturesByLayer[layerURL])];
const mapImageLayer = getMapImageLayer(layerURL);
let mapImageLayerId: string = '';
let sublayer: Sublayer | undefined;
if (mapImageLayer) {
mapImageLayerId = mapImageLayer.id;
mapImageLayers.push(mapImageLayer);
mapImageLayer.sublayers.forEach((layer) => {
if (layer.url === layerURL) {
sublayer = layer;
}
});
}
const text = sublayer?.id ? sublayer?.id.toString() : '';
// create UI item for sublayer
const accordionItem = (
<CalciteAccordionItem heading={sublayer?.title} key={sublayer?.id}>
<CalciteAction
slot="actions-end"
icon={sublayer?.visible ? 'view-visible' : 'view-hide'}
text={text}
appearance="transparent"
onClick={() => {
if (sublayer) {
sublayer.visible = !sublayer.visible;
}
}}
></CalciteAction>
<CalciteList>
{relatedFeatureURLs.map((relatedFeatureURL) => {
const regex = /\d+$/g;
const matches = relatedFeatureURL.match(regex);
let featureId;
if (matches && matches.length > 0) featureId = matches[0];
return (
<CalciteListItem key={featureId}>
<CalciteAction
slot="actions-start"
icon="magnifying-glass"
text={relatedFeatureURL}
appearance="transparent"
onClick={handleZoomTo}
></CalciteAction>
<CalciteLink
href={relatedFeatureURL}
target="_blank"
slot="content"
>{`Feature ${featureId}`}</CalciteLink>
</CalciteListItem>
);
})}
</CalciteList>
</CalciteAccordionItem>
);
if (!relatedFeatures[mapImageLayerId]) {
relatedFeatures[mapImageLayerId] = [accordionItem];
} else {
relatedFeatures[mapImageLayerId].push(accordionItem);
}
});
// create cutom layer objects tree
const layerObjects = getLayerObjects(view.map.layers.toArray());
// remove layers that are not in the seach result
let remainingLayers = removeLayers(layerObjects, (layer: CustomGroupLayer | CustomLayer) => {
if (
layer.type === 'map-image' &&
mapImageLayers.findIndex((mapImageLayer) => mapImageLayer.id === layer.id) === -1
) {
return false;
} else {
return true;
}
});
remainingLayers = removeLayers(remainingLayers, (layer: CustomGroupLayer | CustomLayer) => {
if (
(layer.type === 'group' &&
((layer as CustomGroupLayer).layers.length === 0 ||
(layer as CustomGroupLayer).layers.every(
(child) => child.type === 'imagery' || child.type === 'imagery-tile'
))) ||
layer.type === 'feature' ||
layer.type === 'imagery' ||
layer.type === 'imagery-tile'
) {
return false;
} else {
return true;
}
});
// recursively build UI from remaining layers
const buildLayerTree = (layer: CustomGroupLayer | CustomLayer) => {
if (layer.type === 'group') {
return (
<CalciteAccordionItem heading={layer.title} key={layer.id}>
<CalciteAction
slot="actions-end"
icon={layer.visible ? 'view-visible' : 'view-hide'}
text={layer.id}
appearance="transparent"
onClick={handleToggleVisibility}
></CalciteAction>
{(layer as CustomGroupLayer).layers &&
(layer as CustomGroupLayer).layers.map((sublayer) => buildLayerTree(sublayer))}
</CalciteAccordionItem>
);
} else if (layer.type === 'map-image') {
return (
<CalciteAccordionItem heading={layer.title} key={layer.id}>
<CalciteAction
slot="actions-end"
icon={layer.visible ? 'view-visible' : 'view-hide'}
text={layer.id}
appearance="transparent"
onClick={handleToggleVisibility}
></CalciteAction>
{relatedFeatures[layer.id]}
</CalciteAccordionItem>
);
}
};
const layertree = (
<CalciteAccordion appearance="transparent" iconPosition="start" scale="s">
{remainingLayers.map((layer: CustomGroupLayer | CustomLayer) => buildLayerTree(layer))}
</CalciteAccordion>
);
// render react components into div element
const contentDiv = document.createElement('div');
const root: Root = createRoot(contentDiv);
root.render(layertree);
if (isPopupContent) {
return contentDiv;
} else {
let location;
if (currentGraphicRef.current?.geometry?.type === 'polygon') {
location = (currentGraphicRef.current?.geometry as Polygon).centroid;
} else {
location = currentGraphicRef.current?.geometry;
}
view.openPopup({
title: t('search.popup-title'),
content: contentDiv,
location: location,
});
}
});
};
// create popup content for popup template of index layer
const createPopupContent = async (target: any) => {
const cellcode = target.graphic.attributes['cellcode'];
const query = createQueryFromCellcode(cellcode);
const objectIds = await indexLayer
.queryFeatures({
where: query,
returnGeometry: false,
outFields: ['fid'],
})
.then((resultSet) => {
// return Ids of neighbouring features (3x3 neighbourhood)
return resultSet.features.map((feature) => feature.attributes['fid']);
});
return queryRelatedFeaturesFromOIds(objectIds, true);
};
const popupTemplate = {
title: t('search.popup-title'),
content: createPopupContent,
returnGeometry: false,
outFields: [],
};
indexLayer.popupTemplate = popupTemplate as unknown as PopupTemplate;
});
return null;
}

View file

@ -22,6 +22,10 @@ body {
background: rgb(var(--background-end-rgb));
}
body {
display: flex;
}
* {
box-sizing: border-box;
}