Skip to content
Open
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
138 changes: 134 additions & 4 deletions lib/classes/geojson-layer.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import EsriJSON from 'ol/format/EsriJSON.js';
import GeoJSON from "ol/format/GeoJSON";
import VectorSource from "ol/source/Vector";
import VectorLayer from "ol/layer/Vector";
import Overlay from "ol/Overlay.js";
import { createXYZ } from 'ol/tilegrid.js';
import { tile as tileStrategy } from 'ol/loadingstrategy.js';

Expand All @@ -19,11 +20,15 @@ class GeoJSONLayer extends Layer {
map = null,
type = 'geojson',
visible = true,
fit = false,
tooltip,
onClick
}) {
super({ id, name, source, map, visible });
this.fit = fit
this.style = style
this.type = type
this.tooltip = tooltip
this.onClick = onClick
this._add();
}
Expand All @@ -36,7 +41,7 @@ class GeoJSONLayer extends Layer {
// establishing OL vector layer format
if (this.type === 'geojson') {
// if url, load remote data
if (typeof (this.source) === 'string' && this.source.includes('http')) {
if (typeof (this.source) === 'string') {
this.layer = new VectorLayer({
source: new VectorSource({
url: this.source,
Expand Down Expand Up @@ -108,11 +113,117 @@ class GeoJSONLayer extends Layer {
}

if (typeof this.onClick === 'function') {
this.map.on('click', (e) => {
this._listen('click', (e) => {
this.layer.getFeatures(e.pixel).then((features) => {
const feature = features.length ? features[0] : undefined;
if (!feature) return;
this.onClick(feature, e.coordinate)({ store });
const result = this.onClick(feature, e.coordinate);
if (typeof result === "function") result({ store });
});
});
}

if (typeof this.tooltip === "function") {
const tooltipId = `tooltip-${this.id}`;
this.map.getOverlays().getArray()
.filter((overlay) => overlay.get("id") === tooltipId)
.forEach((overlay) => this.map.removeOverlay(overlay));

const tooltipElement = document.createElement("div");
tooltipElement.style.background = "rgba(17, 24, 39, 0.92)";
tooltipElement.style.borderRadius = "4px";
tooltipElement.style.color = "#fff";
tooltipElement.style.fontSize = "12px";
tooltipElement.style.maxWidth = "18rem";
tooltipElement.style.padding = "6px 8px";
tooltipElement.style.pointerEvents = "none";
tooltipElement.style.position = "relative";
tooltipElement.style.whiteSpace = "pre-line";
tooltipElement.style.zIndex = "10000";
tooltipElement.style.display = "none";
tooltipElement.dataset.groundworkGeoTooltip = "true";

const tooltipOverlay = new Overlay({
element: tooltipElement,
offset: [0, -12],
positioning: "bottom-center",
});
tooltipOverlay.set("id", tooltipId);
this._addOverlay(tooltipOverlay);
const raiseTooltipOverlay = () => {
const container = tooltipElement.parentElement;
if (container) {
container.style.pointerEvents = "none";
container.style.zIndex = "10000";
const overlayRoot = container.parentElement;
if (overlayRoot) {
overlayRoot.style.zIndex = "10000";
}
}
};
const hidePeerTooltips = () => {
this.map.getOverlays().getArray()
.filter((overlay) => overlay.get("id")?.startsWith("tooltip-"))
.forEach((overlay) => {
if (overlay !== tooltipOverlay) {
const element = overlay.getElement?.();
if (element) element.style.display = "none";
}
});
};
raiseTooltipOverlay();

this._listen("pointermove", (e) => {
this.layer.getFeatures(e.pixel).then((features) => {
const feature = features.length ? features[0] : undefined;
const text = feature ? this.tooltip(feature, e.coordinate) : "";
if (!text) {
tooltipElement.style.display = "none";
return;
}
const mapElement = this.map.getTargetElement?.();
const mapWidth = mapElement?.clientWidth || 0;
const mapHeight = mapElement?.clientHeight || 0;
const nearLeft = e.pixel[0] < 160;
const nearRight = mapWidth && e.pixel[0] > mapWidth - 160;
const nearTop = e.pixel[1] < 80;
const nearBottom = mapHeight && e.pixel[1] > mapHeight - 80;

if (mapWidth) {
tooltipElement.style.maxWidth = `${Math.max(160, Math.min(288, mapWidth - 16))}px`;
}

if (nearTop && nearLeft) {
tooltipOverlay.setPositioning("top-left");
tooltipOverlay.setOffset([8, 12]);
} else if (nearTop && nearRight) {
tooltipOverlay.setPositioning("top-right");
tooltipOverlay.setOffset([-8, 12]);
} else if (nearBottom && nearLeft) {
tooltipOverlay.setPositioning("bottom-left");
tooltipOverlay.setOffset([8, -12]);
} else if (nearBottom && nearRight) {
tooltipOverlay.setPositioning("bottom-right");
tooltipOverlay.setOffset([-8, -12]);
} else if (nearTop) {
tooltipOverlay.setPositioning("top-center");
tooltipOverlay.setOffset([0, 12]);
} else if (nearLeft) {
tooltipOverlay.setPositioning("bottom-left");
tooltipOverlay.setOffset([8, -12]);
} else if (nearRight) {
tooltipOverlay.setPositioning("bottom-right");
tooltipOverlay.setOffset([-8, -12]);
} else {
tooltipOverlay.setPositioning("bottom-center");
tooltipOverlay.setOffset([0, -12]);
}

hidePeerTooltips();
tooltipElement.textContent = text;
tooltipElement.style.display = "block";
tooltipOverlay.setPosition(e.coordinate);
raiseTooltipOverlay();
});
});
}
Expand All @@ -121,7 +232,26 @@ class GeoJSONLayer extends Layer {
// calling OL map add layer function
this.map.addLayer(this.layer)

this._setAdded = true
if (this.fit) {
const fitToSource = () => {
const source = this.layer?.getSource?.();
if (!source || !source.getFeatures().length) return;
this.map.getView().fit(source.getExtent(), {
padding: [32, 32, 32, 32],
maxZoom: 9,
duration: 250,
});
};
const source = this.layer.getSource();
if (source.getState?.() === "ready" || source.getFeatures().length) {
fitToSource();
} else {
source.once("featuresloadend", fitToSource);
source.once("change", fitToSource);
}
}

this._setAdded()
}
}

Expand Down
28 changes: 28 additions & 0 deletions lib/classes/layer.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ class Layer {
this.added = false;

this.layer = null;
this._mapListeners = [];
this._overlays = [];
}

show() {
Expand All @@ -18,6 +20,32 @@ class Layer {
this.layer.setVisible = false;
}

_listen(type, listener) {
this.map.on(type, listener);
this._mapListeners.push({ listener, type });
}

_addOverlay(overlay) {
this.map.addOverlay(overlay);
this._overlays.push(overlay);
}

_remove() {
if (!this.map) return;

this._mapListeners.forEach(({ listener, type }) => {
this.map.un(type, listener);
});
this._mapListeners = [];

this._overlays.forEach((overlay) => this.map.removeOverlay(overlay));
this._overlays = [];

if (this.layer) this.map.removeLayer(this.layer);
this.layer = null;
this.added = false;
}

_setAdded() {
this.added = true;
}
Expand Down
154 changes: 154 additions & 0 deletions lib/classes/status-marker-layer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import Feature from "ol/Feature";
import Point from "ol/geom/Point";
import VectorLayer from "ol/layer/Vector";
import VectorSource from "ol/source/Vector";
import { fromLonLat } from "ol/proj";

import Layer from "./layer";
import {
createStatusMarkerStyle,
getThresholdStatus,
statusColor,
} from "../styles/status-marker-style";

function defaultCoordinateAccessor(item) {
const lon = item.longitude ?? item.lon ?? item.x;
const lat = item.latitude ?? item.lat ?? item.y;

return [Number(lon), Number(lat)];
}

function defaultShapeAccessor(item) {
const type = String(item.type ?? item.locationType ?? item.kind ?? "").toLowerCase();

if (type.includes("reservoir") || type.includes("project")) return "triangle";
if (type.includes("lock")) return "square";
return "diamond";
}

function defaultLabelAccessor(item) {
return item.mapLabel ?? item.publicName ?? item.name ?? item.id;
}

function normalizeFeatures(data, coordinateAccessor) {
const entries = data?.type === "FeatureCollection" ? data.features : data;

return (entries || [])
.map((entry) => {
const properties = entry.type === "Feature" ? entry.properties || {} : entry;
const coordinates =
entry.type === "Feature"
? entry.geometry?.coordinates
: coordinateAccessor(properties);

if (!coordinates || coordinates.length < 2) return null;

const lon = Number(coordinates[0]);
const lat = Number(coordinates[1]);

if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null;

const feature = new Feature({
geometry: new Point(fromLonLat([lon, lat])),
...properties,
});

return feature;
})
.filter(Boolean);
}

class StatusMarkerLayer extends Layer {
constructor({
id,
name,
source = [],
map = null,
visible = true,
fit = false,
thresholds,
colors,
radius = 9,
coordinateAccessor = defaultCoordinateAccessor,
shapeAccessor = defaultShapeAccessor,
labelAccessor = defaultLabelAccessor,
statusAccessor,
style,
onClick,
}) {
super({ id, name, source, map, visible });
this.colors = colors;
this.coordinateAccessor = coordinateAccessor;
this.fit = fit;
this.labelAccessor = labelAccessor;
this.onClick = onClick;
this.radius = radius;
this.shapeAccessor = shapeAccessor;
this.statusAccessor = statusAccessor;
this.style = style;
this.thresholds = thresholds;
this._add();
}

_getStyle(feature) {
if (typeof this.style === "function") return this.style(feature);
if (this.style) return this.style;

const properties = feature.getProperties();
let status =
typeof this.statusAccessor === "function"
? this.statusAccessor(properties)
: undefined;

if (status === null || status === undefined) {
status = getThresholdStatus(properties.statusValue ?? properties.value, this.thresholds);
}

return createStatusMarkerStyle({
color: statusColor(status, this.colors),
label: this.labelAccessor ? this.labelAccessor(properties) : "",
radius: this.radius,
shape: this.shapeAccessor ? this.shapeAccessor(properties) : "circle",
});
}

_add() {
if (!this.map || this.added) return;

const source = new VectorSource({
features: normalizeFeatures(this.source, this.coordinateAccessor),
});

this.layer = new VectorLayer({
source,
style: (feature) => this._getStyle(feature),
zIndex: 1000,
visible: this.visible,
});
this.layer.show = this.visible;

if (typeof this.onClick === "function") {
this._listen("click", (event) => {
this.layer.getFeatures(event.pixel).then((features) => {
const feature = features[0];
if (feature) this.onClick(feature, event.coordinate);
});
});
}

this.map.addLayer(this.layer);

if (this.fit && source.getFeatures().length) {
this.map.getView().fit(source.getExtent(), {
padding: [32, 32, 32, 32],
maxZoom: 11,
duration: 250,
});
}

this._setAdded();
}
}

export default StatusMarkerLayer;
export { StatusMarkerLayer };
Loading
Loading