diff --git a/oceannavigator/frontend/src/components/map/drawing.js b/oceannavigator/frontend/src/components/map/drawing.js
new file mode 100644
index 00000000..927c3835
--- /dev/null
+++ b/oceannavigator/frontend/src/components/map/drawing.js
@@ -0,0 +1,138 @@
+import Feature from "ol/Feature.js";
+import * as olExtent from "ol/extent";
+import * as olinteraction from "ol/interaction";
+import * as olgeom from "ol/geom";
+import * as olProj from "ol/proj";
+import { getDistance } from "ol/sphere";
+
+export const drawAction = (vectorSource, vectorType, projection, action) => {
+ const drawAction = new olinteraction.Draw({
+ source: vectorSource,
+ type: "Point",
+ stopClick: true,
+ wrapX: true,
+ });
+
+ drawAction.set("type", vectorType);
+ drawAction.on("drawend", function (e) {
+ // Disable zooming when drawing
+ const latlon = olProj
+ .transform(
+ e.feature.getGeometry().getCoordinates(),
+ projection,
+ "EPSG:4326"
+ )
+ .reverse();
+ // Draw point on map(s)
+ action("addPoints", [latlon]);
+ });
+ return drawAction
+ };
+
+export const pointFeature = (vectorType, vectorCoordinates, projection) => {
+ let geom;
+ let feat;
+ if ((vectorType === "point") | (vectorCoordinates.length < 2)) {
+ for (let c of vectorCoordinates) {
+ geom = new olgeom.Point([c[1], c[0]]);
+ geom = geom.transform("EPSG:4326", projection);
+ feat = new Feature({
+ geometry: geom,
+ name: c[0].toFixed(4) + ", " + c[1].toFixed(4),
+ type: "point",
+ });
+ }
+ } else if (vectorType === "line") {
+ geom = new olgeom.LineString(
+ vectorCoordinates.map(function (c) {
+ return [c[1], c[0]];
+ })
+ );
+
+ geom.transform("EPSG:4326", projection);
+ feat = new Feature({
+ geometry: geom,
+ type: "line",
+ });
+ } else if (vectorType === "area") {
+ geom = new olgeom.Polygon([
+ vectorCoordinates.map(function (c) {
+ return [c[1], c[0]];
+ }),
+ ]);
+ const centroid = olExtent.getCenter(geom.getExtent());
+ geom.transform("EPSG:4326", projection);
+ feat = new Feature({
+ geometry: geom,
+ type: "area",
+ centroid: centroid,
+ });
+ }
+
+ return feat;
+};
+
+export const getLineDistance = (line) => {
+ var dist = 0;
+ for (let i = 1; i < line.length; i++) {
+ let start = [line[i - 1][1], line[i - 1][0]];
+ let end = [line[i][1], line[i][0]];
+ dist += getDistance(start, end);
+ }
+
+ return dist;
+ };
+
+export const obsPointDrawAction = (map, obsDrawSource, projection, action) => {
+ const drawAction = new olinteraction.Draw({
+ source: obsDrawSource,
+ type: "Point",
+ stopClick: true,
+ });
+ drawAction.set("type", "Point");
+ drawAction.on("drawend", function (e) {
+ // Disable zooming when drawing
+ const lonlat = olProj.transform(
+ e.feature.getGeometry().getCoordinates(),
+ projection,
+ "EPSG:4326"
+ );
+
+ // Send area to Observation Selector
+ obsDrawSource.clear();
+ action("setObsArea", [[lonlat[1], lonlat[0]]]);
+
+ map.removeInteraction(drawAction);
+ });
+
+ return drawAction
+}
+
+export const obsAreaDrawAction = (map, obsDrawSource, projection, action) => {
+ const draw = new olinteraction.Draw({
+ source: obsDrawSource,
+ type: "Polygon",
+ stopClick: true,
+ });
+ draw.set("type", "Polygon");
+ draw.on("drawend", function (e) {
+ // Disable zooming when drawing
+ const points = e.feature
+ .getGeometry()
+ .getCoordinates()[0]
+ .map(function (c) {
+ const lonlat = olProj.transform(
+ c,
+ projection,
+ "EPSG:4326"
+ );
+ return [lonlat[1], lonlat[0]];
+ });
+ // Send area to Observation Selector
+ action("setObsArea", points);
+ map.removeInteraction(draw);
+ setTimeout(function () {
+ obsDrawSource.clear();
+ }, 251);
+ });
+}
\ No newline at end of file
diff --git a/oceannavigator/frontend/src/components/map/utils.js b/oceannavigator/frontend/src/components/map/utils.js
new file mode 100644
index 00000000..fd3245e0
--- /dev/null
+++ b/oceannavigator/frontend/src/components/map/utils.js
@@ -0,0 +1,674 @@
+import { renderToString } from "react-dom/server";
+import axios from "axios";
+import Map from "ol/Map.js";
+import TileLayer from "ol/layer/Tile";
+import {
+ Style,
+ Circle,
+ Icon,
+ Stroke,
+ Fill,
+ Text,
+ RegularShape,
+} from "ol/style";
+import VectorTile from "ol/source/VectorTile";
+import VectorTileLayer from "ol/layer/VectorTile.js";
+import VectorLayer from "ol/layer/Vector.js";
+import MVT from "ol/format/MVT.js";
+import XYZ from "ol/source/XYZ";
+import { defaults as defaultControls } from "ol/control/defaults";
+import MousePosition from "ol/control/MousePosition.js";
+import Graticule from "ol/layer/Graticule.js";
+import * as olinteraction from "ol/interaction";
+import * as olcondition from "ol/events/condition";
+import * as olgeom from "ol/geom";
+import * as olProj from "ol/proj";
+import * as olTilegrid from "ol/tilegrid";
+import { isMobile } from "react-device-detect";
+
+function deg2rad(deg) {
+ return (deg * Math.PI) / 180.0;
+}
+
+// CHS S111 standard arrows for quiver layer
+const I0 = require("../../images/s111/I0.svg").default; // lgtm [js/unused-local-variable]
+const I1 = require("../../images/s111/I1.svg").default;
+const I2 = require("../../images/s111/I2.svg").default;
+const I3 = require("../../images/s111/I3.svg").default;
+const I4 = require("../../images/s111/I4.svg").default;
+const I5 = require("../../images/s111/I5.svg").default;
+const I6 = require("../../images/s111/I6.svg").default;
+const I7 = require("../../images/s111/I7.svg").default;
+const I8 = require("../../images/s111/I8.svg").default;
+const I9 = require("../../images/s111/I9.svg").default;
+
+const arrowImages = [I0, I1, I2, I3, I4, I5, I6, I7, I8, I9];
+
+const COLORS = [
+ [0, 0, 255],
+ [0, 128, 0],
+ [255, 0, 0],
+ [0, 255, 255],
+ [255, 0, 255],
+ [255, 255, 0],
+ [0, 0, 0],
+ [255, 255, 255],
+];
+
+var drifter_color = {};
+
+const getBasemap = (source, projection, attribution, topoShadedRelief) => {
+ switch (source) {
+ case "topo":
+ const shadedRelief = topoShadedRelief ? "true" : "false";
+ return new TileLayer({
+ preload: 1,
+ source: new XYZ({
+ url: `/api/v2.0/tiles/topo/{z}/{x}/{y}?shaded_relief=${shadedRelief}&projection=${projection}`,
+ projection: projection,
+ }),
+ });
+ case "ocean":
+ return new TileLayer({
+ preload: 1,
+ source: new XYZ({
+ url: "https://server.arcgisonline.com/ArcGIS/rest/services/Ocean/World_Ocean_Base/MapServer/tile/{z}/{y}/{x}",
+ projection: "EPSG:3857",
+ }),
+ });
+ case "world":
+ return new TileLayer({
+ preload: 1,
+ source: new XYZ({
+ url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
+ projection: "EPSG:3857",
+ }),
+ });
+ case "chs":
+ return new TileLayer({
+ source: new TileWMS({
+ url: "https://gisp.dfo-mpo.gc.ca/arcgis/rest/services/CHS/ENC_MaritimeChartService/MapServer/exts/MaritimeChartService/WMSServer",
+ params: {
+ LAYERS: "1:1",
+ },
+ projection: "EPSG:3857",
+ }),
+ });
+ }
+};
+
+export const createMap = (
+ mapSettings,
+ overlay,
+ popupElement,
+ newMapView,
+ newLayerData,
+ newVectorSource,
+ newObsDrawSource,
+ maxZoom,
+ mapRef
+) => {
+ const newLayerBasemap = getBasemap(
+ mapSettings.basemap,
+ mapSettings.projection,
+ mapSettings.basemap_attribution,
+ mapSettings.topoShadedRelief
+ );
+
+ const vectorTileGrid = new olTilegrid.createXYZ({
+ tileSize: 512,
+ maxZoom: maxZoom,
+ });
+
+ const newLayerLandShapes = new VectorTileLayer({
+ opacity: 1,
+ style: new Style({
+ stroke: new Stroke({
+ color: "rgba(0, 0, 0, 1)",
+ }),
+ fill: new Fill({
+ color: "white",
+ }),
+ }),
+ source: new VectorTile({
+ format: new MVT(),
+ tileGrid: vectorTileGrid,
+ tilePixelRatio: 8,
+ url: `/api/v2.0/mbt/lands/{z}/{x}/{y}?projection=${mapSettings.projection}`,
+ projection: mapSettings.projection,
+ }),
+ });
+
+ const newLayerBath = new TileLayer({
+ source: new XYZ({
+ url: `/api/v2.0/tiles/bath/{z}/{x}/{y}?projection=${mapSettings.projection}`,
+ projection: mapSettings.projection,
+ }),
+ opacity: mapSettings.mapBathymetryOpacity,
+ visible: mapSettings.bathymetry,
+ preload: 1,
+ });
+
+ const newLayerBathShapes = new VectorTileLayer({
+ opacity: mapSettings.mapBathymetryOpacity,
+ visible: mapSettings.bathymetry,
+ style: new Style({
+ stroke: new Stroke({
+ color: "rgba(0, 0, 0, 1)",
+ }),
+ }),
+ source: new VectorTile({
+ format: new MVT(),
+ tileGrid: vectorTileGrid,
+ tilePixelRatio: 8,
+ url: `/api/v2.0/mbt/bath/{z}/{x}/{y}?projection=${mapSettings.projection}`,
+ projection: mapSettings.projection,
+ }),
+ });
+
+ const newLayerVector = new VectorLayer({
+ source: newVectorSource,
+ style: function (feat, res) {
+ if (feat.get("class") == "observation") {
+ if (feat.getGeometry() instanceof olgeom.LineString) {
+ let color = drifter_color[feat.get("id")];
+
+ if (color === undefined) {
+ color = COLORS[Object.keys(drifter_color).length % COLORS.length];
+ drifter_color[feat.get("id")] = color;
+ }
+ const styles = [
+ new Style({
+ stroke: new Stroke({
+ color: [color[0], color[1], color[2], 0.004],
+ width: 8,
+ }),
+ }),
+ new Style({
+ stroke: new Stroke({
+ color: color,
+ width: isMobile ? 4 : 2,
+ }),
+ }),
+ ];
+
+ return styles;
+ }
+
+ let image = new Circle({
+ radius: isMobile ? 6 : 4,
+ fill: new Fill({
+ color: "#ff0000",
+ }),
+ stroke: new Stroke({
+ color: "#000000",
+ width: 1,
+ }),
+ });
+ let stroke = new Stroke({ color: "#000000", width: 1 });
+ let radius = isMobile ? 9 : 6;
+ switch (feat.get("type")) {
+ case "argo":
+ image = new Circle({
+ radius: isMobile ? 6 : 4,
+ fill: new Fill({ color: "#ff0000" }),
+ stroke: stroke,
+ });
+ break;
+ case "mission":
+ image = new RegularShape({
+ points: 3,
+ radius: radius,
+ fill: new Fill({ color: "#ffff00" }),
+ stroke: stroke,
+ });
+ break;
+ case "drifter":
+ image = new RegularShape({
+ points: 4,
+ radius: radius,
+ fill: new Fill({ color: "#00ff00" }),
+ stroke: stroke,
+ });
+ break;
+ case "glider":
+ image = new RegularShape({
+ points: 5,
+ radius: radius,
+ fill: new Fill({ color: "#00ffff" }),
+ stroke: stroke,
+ });
+ break;
+ case "animal":
+ image = new RegularShape({
+ points: 6,
+ radius: radius,
+ fill: new Fill({ color: "#0000ff" }),
+ stroke: stroke,
+ });
+ break;
+ }
+ return new Style({ image: image });
+ } else {
+ switch (feat.get("type")) {
+ case "area":
+ if (feat.get("key")) {
+ return [
+ new Style({
+ stroke: new Stroke({
+ color: "#ffffff",
+ width: 2,
+ }),
+ fill: new Fill({
+ color: "#ffffff00",
+ }),
+ }),
+ new Style({
+ stroke: new Stroke({
+ color: "#000000",
+ width: 1,
+ }),
+ }),
+ new Style({
+ geometry: new olgeom.Point(
+ olProj.transform(
+ feat.get("centroid"),
+ "EPSG:4326",
+ mapSettings.projection
+ )
+ ),
+ text: new Text({
+ text: feat.get("name"),
+ font: "14px sans-serif",
+ fill: new Fill({
+ color: "#000",
+ }),
+ stroke: new Stroke({
+ color: "#ffffff",
+ width: 2,
+ }),
+ }),
+ }),
+ ];
+ } else {
+ return [
+ new Style({
+ stroke: new Stroke({
+ color: "#ffffff",
+ width: 5,
+ }),
+ }),
+ new Style({
+ stroke: new Stroke({
+ color: "#ff0000",
+ width: 3,
+ }),
+ }),
+ ];
+ }
+ case "line":
+ return [
+ new Style({
+ stroke: new Stroke({
+ color: "#ffffff",
+ width: 5,
+ }),
+ }),
+ new Style({
+ stroke: new Stroke({
+ color: "#ff0000",
+ width: 3,
+ }),
+ }),
+ ];
+ case "point":
+ return new Style({
+ image: new Circle({
+ radius: 4,
+ fill: new Fill({
+ color: "#ff0000",
+ }),
+ stroke: new Stroke({
+ color: "#ffffff",
+ width: 2,
+ }),
+ }),
+ });
+ case "GKHdrifter": {
+ const start = feat.getGeometry().getCoordinateAt(0);
+ const end = feat.getGeometry().getCoordinateAt(1);
+ let endImage;
+ let color = drifter_color[feat.get("name")];
+
+ if (color === undefined) {
+ color = COLORS[Object.keys(drifter_color).length % COLORS.length];
+ drifter_color[feat.get("name")] = color;
+ }
+ if (
+ feat.get("status") == "inactive" ||
+ feat.get("status") == "not responding"
+ ) {
+ endImage = new Icon({
+ src: X_IMAGE,
+ scale: 0.75,
+ });
+ } else {
+ endImage = new Circle({
+ radius: isMobile ? 6 : 4,
+ fill: new Fill({
+ color: "#ff0000",
+ }),
+ stroke: new Stroke({
+ color: "#000000",
+ width: 1,
+ }),
+ });
+ }
+
+ const styles = [
+ new Style({
+ stroke: new Stroke({
+ color: [color[0], color[1], color[2], 0.004],
+ width: 8,
+ }),
+ }),
+ new Style({
+ stroke: new Stroke({
+ color: color,
+ width: isMobile ? 4 : 2,
+ }),
+ }),
+ new Style({
+ geometry: new olgeom.Point(end),
+ image: endImage,
+ }),
+ new Style({
+ geometry: new olgeom.Point(start),
+ image: new Circle({
+ radius: isMobile ? 6 : 4,
+ fill: new Fill({
+ color: "#008000",
+ }),
+ stroke: new Stroke({
+ color: "#000000",
+ width: 1,
+ }),
+ }),
+ }),
+ ];
+
+ return styles;
+ }
+
+ case "class4": {
+ const red = Math.min(255, 255 * (feat.get("error_norm") / 0.5));
+ const green = Math.min(
+ 255,
+ (255 * (1 - feat.get("error_norm"))) / 0.5
+ );
+
+ return new Style({
+ image: new Circle({
+ radius: isMobile ? 6 : 4,
+ fill: new Fill({
+ color: [red, green, 0, 1],
+ }),
+ stroke: new Stroke({
+ color: "#000000",
+ width: 1,
+ }),
+ }),
+ });
+ }
+ }
+ }
+ },
+ });
+
+ const newLayerObsDraw = new VectorLayer({ source: newObsDrawSource });
+
+ const anchor = [0.5, 0.5];
+ const newLayerQuiver = new VectorTileLayer({
+ source: null, // set source during update function below
+ style: function (feature, resolution) {
+ let scale = feature.get("scale");
+ let rotation = null;
+ if (!feature.get("bearing")) {
+ // bearing-only variable (no magnitude)
+ rotation = deg2rad(parseFloat(feature.get("data")));
+ } else {
+ rotation = deg2rad(parseFloat(feature.get("bearing")));
+ }
+ return new Style({
+ image: new Icon({
+ scale: 0.2 + (scale + 1) / 16,
+ src: arrowImages[scale],
+ opacity: 1,
+ anchor: anchor,
+ rotation: rotation,
+ }),
+ });
+ },
+ });
+
+ let options = {
+ view: newMapView,
+ layers: [
+ newLayerBasemap,
+ newLayerData,
+ newLayerLandShapes,
+ newLayerBath,
+ newLayerBathShapes,
+ newLayerVector,
+ newLayerObsDraw,
+ newLayerQuiver,
+ ],
+ controls: defaultControls({
+ zoom: true,
+ }).extend([
+ new MousePosition({
+ projection: "EPSG:4326",
+ coordinateFormat: function (c) {
+ return "
" + c[1].toFixed(4) + ", " + c[0].toFixed(4) + "
";
+ },
+ }),
+ new Graticule({
+ strokeStyle: new Stroke({
+ color: "rgba(128, 128, 128, 0.9)",
+ lineDash: [0.5, 4],
+ }),
+ }),
+ ]),
+
+ overlays: [overlay],
+ };
+
+ let mapObject = new Map(options);
+ mapObject.setTarget(mapRef.current);
+
+ let selected = null;
+ mapObject.on("pointermove", function (e) {
+ if (selected !== null) {
+ selected.setStyle(undefined);
+ selected = null;
+ }
+ const feature = mapObject.forEachFeatureAtPixel(
+ mapObject.getEventPixel(e.originalEvent),
+ function (feature, layer) {
+ return feature;
+ }
+ );
+ if (feature && feature.get("name")) {
+ overlay.setPosition(e.coordinate);
+ if (feature.get("data")) {
+ let bearing = feature.get("bearing");
+ popupElement.current.innerHTML = renderToString(
+
+
+ Variable |
+ {feature.get("name")} |
+
+
+ Data |
+ {feature.get("data")} |
+
+
+ Units |
+ {feature.get("units")} |
+
+ {bearing && (
+
+ Bearing (+ve deg clockwise N) |
+ {bearing} |
+
+ )}
+
+ );
+ } else {
+ popupElement.current.innerHTML = feature.get("name");
+ }
+
+ if (feature.get("type") == "area") {
+ mapObject.forEachFeatureAtPixel(e.pixel, function (f) {
+ selected = f;
+ f.setStyle([
+ new Style({
+ stroke: new Stroke({
+ color: "#ffffff",
+ width: 2,
+ }),
+ fill: new Fill({
+ color: "#ffffff80",
+ }),
+ }),
+ new Style({
+ stroke: new Stroke({
+ color: "#000000",
+ width: 1,
+ }),
+ }),
+ new Style({
+ geometry: new olgeom.Point(
+ olProj.transform(
+ f.get("centroid"),
+ "EPSG:4326",
+ mapSettings.projection
+ )
+ ),
+ text: new Text({
+ text: f.get("name"),
+ font: "14px sans-serif",
+ fill: new Fill({
+ color: "#000000",
+ }),
+ stroke: new Stroke({
+ color: "#ffffff",
+ width: 2,
+ }),
+ }),
+ }),
+ ]);
+ return true;
+ });
+ }
+ } else if (feature && feature.get("class") == "observation") {
+ if (feature.get("meta")) {
+ overlay.setPosition(e.coordinate);
+ popupElement.current.innerHTML = feature.get("meta");
+ } else {
+ let type = "station";
+ if (feature.getGeometry() instanceof olgeom.LineString) {
+ type = "platform";
+ }
+ axios
+ .get(`/api/v2.0/observation/meta/${type}/${feature.get("id")}}.json`)
+ .then(function (response) {
+ overlay.setPosition(e.coordinate);
+ feature.set(
+ "meta",
+ renderToString(
+
+ {Object.keys(response.data).map((key) => (
+
+ {key} |
+ {response.data[key]} |
+
+ ))}
+
+ )
+ );
+ popupElement.current.innerHTML = feature.get("meta");
+ })
+ .catch();
+ }
+ } else {
+ overlay.setPosition(undefined);
+ }
+ });
+
+ mapObject.on("pointermove", function (e) {
+ var pixel = mapObject.getEventPixel(e.originalEvent);
+ var hit = mapObject.hasFeatureAtPixel(pixel);
+ mapObject.getViewport().style.cursor = hit ? "pointer" : "";
+ });
+
+ const dragBox = new olinteraction.DragBox({
+ condition: olcondition.platformModifierKeyOnly,
+ });
+ mapObject.addInteraction(dragBox);
+
+ newLayerBasemap.setZIndex(0);
+ newLayerLandShapes.setZIndex(2);
+ newLayerBath.setZIndex(3);
+ newLayerBathShapes.setZIndex(4);
+ newLayerVector.setZIndex(5);
+ newLayerObsDraw.setZIndex(6);
+ newLayerQuiver.setZIndex(100);
+
+ return mapObject;
+};
+
+export const getDataSource = (dataset, mapSettings) => {
+ let scale = dataset.variable_scale;
+ if (Array.isArray(scale)) {
+ scale = scale.join(",");
+ }
+
+ let dataSource = {};
+ dataSource.url =
+ "/api/v2.0/tiles" +
+ `/${dataset.id}` +
+ `/${dataset.variable}` +
+ `/${dataset.time}` +
+ `/${dataset.depth}` +
+ "/{z}/{x}/{y}" +
+ `?projection=${mapSettings.projection}` +
+ `&scale=${scale}` +
+ `&interp=${mapSettings.interpType}` +
+ `&radius=${mapSettings.interpRadius}` +
+ `&neighbours=${mapSettings.interpNeighbours}`;
+ dataSource.projection = mapSettings.projection;
+
+ return dataSource;
+};
+
+export const getQuiverSource = (dataset, mapSettings) => {
+ const quiverSource = new VectorTile({
+ url:
+ "/api/v2.0/tiles/quiver" +
+ `/${dataset.id}` +
+ `/${dataset.quiverVariable}` +
+ `/${dataset.time}` +
+ `/${dataset.depth}` +
+ `/${dataset.quiverDensity}` +
+ "/{z}/{x}/{y}" +
+ `?projection=${mapSettings.projection}`,
+ projection: mapSettings.projection,
+ format: new GeoJSON({
+ featureProjection: olProj.get("EPSG:3857"),
+ dataProjection: olProj.get("EPSG:4326"),
+ }),
+ });
+
+ return quiverSource;
+};