DEV Community

Maciej Krawczyk
Maciej Krawczyk

Posted on • Originally published at Medium on

3 2

Keystone.js custom fields: map component


Photo by Bicanski on Pixnio

Background

This article is the second one in the series about Keystone.js custom fields. Here you can find the first one.

Projects I am usually working one are rather small but has its own specificity due to their background (more about that in first article). In most cases they contain full-fledged CMS system and custom-built data collection tool. But last one left me wondering is there any way to kill two birds with one stone and simplify whole system. Solution here was to use Keystone.js possibility to create models for content data but also for research qualitative data. The only challenge here was that built-in set of fields is nice but in some cases too basic. Here we are going to focus on map field allowing user to store localization of points in database (coordinates to be exact). Like in the previous one, built-in text field was sufficient to store data, but I had to create visual part of this input almost from scratch.

Requirements

Main goal here was to create field showing interactive map to the user allowing to zoom and pan view and also click to add point. Then as a result save coordinates of this point into database. Also, we have to store this data into text field. Alternatively it can be stored in two separate columns in database, one for latitude and one for longitude. But I believe it’s more complicated solution, it requires custom field controller and also changing backend part of the field (see details). In that case solution with one text field seams like much better. To sum up, we need to:

  • Display map,
  • Add controls (pan and zoom),
  • Add possibility to add point to map,
  • Save point coordinates to database.

Component creation

Fortunately we don’t have to build everything from scratch. Most of the heavy lifting will be handled by OpenLayers library. There are many NPM packages handling maps, but the most important advantage of this one is great and complete documentation (most parts). So first we have to add it to our Keystone project:

$ yarn add ol
Enter fullscreen mode Exit fullscreen mode

I am using Yarn here, but also you can install it using NPM:

$ npm i ol
Enter fullscreen mode Exit fullscreen mode

Additionally, due to some dependencies mismatch I had to install separately geotiff.js, depending on actual version at the moment you read this it may not be necessary.

$ yarn add geotiff
Enter fullscreen mode Exit fullscreen mode

Like in previous component I’ve created separate subfolder coordinates for this field in views folder. Basic component structure is the same as in previous component, so we have to import controller, Cell and CardValue from built-in version of text component and reexport them. Also, I’ve setup basic JSX using built-in FieldContainer and FieldLabel components.

import React, { useEffect, useRef } from 'react';
import {
controller as StandardController,
Cell as StandardCell,
CardValue as StandardCardValue,
} from '@keystone-6/core/fields/types/text/views';
import { FieldProps } from '@keystone-6/core/types';
import { FieldContainer, FieldLabel } from '@keystone-ui/fields';
export const Cell = StandardCell;
export const CardValue = StandardCardValue;
export const controller = StandardController;
export const Field = (props: FieldProps<typeof StandardController>) => {
const mapRef = useRef<HTMLDivElement>(null);
useEffect(() => {}, []);
return (
<FieldContainer>
<FieldLabel>{props.field.label}</FieldLabel>
<div ref={mapRef}></div>
</FieldContainer>
);
}
view raw base.tsx hosted with ❤ by GitHub

The base of our map component here is this div tag. And basically that’s all JSX needed. Whole logic and map rendering is going to happen inside this useEffect hook. Additionally, I had to add useRef hook to keep reference to that mentioned before div.

First, we need to import needed elements from ol library:

import Map from 'ol/Map';
import View from 'ol/View';
import TileLayer from 'ol/layer/Tile';
import VectorLayer from 'ol/layer/Vector';
import { TileImage, Vector as VectorSource } from 'ol/source';
import Feature from 'ol/Feature';
import Point from 'ol/geom/Point';
import { Circle as CircleStyle, Fill, Stroke, Style } from 'ol/style';
view raw ol_imports.tsx hosted with ❤ by GitHub

Basically map created with OpenLayers is only a container, we have to add layers in order to present our desired map. First, I created base map layer source using TileImage class and map tiles from Digital Atlas of the Roman Empire (more info):

const source = new TileImage({
url: 'https://dh.gu.se/tiles/imperium/{z}/{x}/{y}.png',
});
view raw tile_source.tsx hosted with ❤ by GitHub

Then, I had to create map instance:

const map = new Map({
target: mapRef.current,
layers: [
new TileLayer({ source }),
],
view: new View({
center: [4185385.9208, 3921831.0473],
zoom: 7,
}),
});
view raw map.tsx hosted with ❤ by GitHub

Here as you can see Map requires a couple of configuration properties. First, we have to set reference to DOM element which will contain our map, mapRef.current in that case. Next property is an array of initially created layers. Here I’ve created TileLayer based of source created before. Last property here is view, it sets map initial center (coordinates, here in EPSG:3857 coordinate system) and zoom. Actually this is the only one obligatory property when creating map (docs). After this steps, we have ready map visualization which can be controlled by user. Next, we have to add another layer to hold point created by user. In this case it’s VectorLayer with corresponding VectorSource and set of styles for points. And then we have to add it into our existing map.

const vectorSource = new VectorSource();
const vectorLayer = new VectorLayer({
source: vectorSource,
style: new Style({
image: new CircleStyle({
radius: 5,
fill: new Fill({ color: 'rgba(255, 0, 0, 0.1)' }),
stroke: new Stroke({ color: 'red', width: 1 }),
}),
}),
});
map.addLayer(vectorLayer);
view raw vector.tsx hosted with ❤ by GitHub

Additionally, here I’ve created styling for the point added by the user. In order to do that I have to instantiate Style object with configuration with property image. There are other ways of doing it, but I prefer this one (check docs). Value of this property is instance of Circle class (in my case aliased as CircleStyles), with configuration object containing radius, fill and stroke props. Also last two are instances of corresponding classes. Basically it sets point visualization to circle with radius of 5 pixels, red, slightly transparent fill and opaque red border. Now map is ready to add our custom handler for singleclick event to allow user to add point. But first we need a way to store our point coordinates:

let lat: number, lon: number;
if (props.value.inner.kind === 'value') {
const coords = props.value.inner.value.split(';');
lat = Number(coords[0]);
lon = Number(coords[1]);
}
view raw variables.tsx hosted with ❤ by GitHub

Also, here in case of situation when field already have value (e.g., when we are editing the record) we are setting coordinates variables to this value. This little complicated way of reading value is mostly caused by the way that Keystone internally handles data for text field. Ok, next we have to create handler for the event I’ve mentioned before:

map.on('singleclick', (evt) => {
const [latValue, lonValue] = evt.coordinate;
lat = latValue;
lon = lonValue;
if (props.onChange) {
props.onChange({
...props.value,
inner: { kind: 'value', value: [lat, lon].join(';') },
});
}
addPointAndFocus();
});
view raw handler.tsx hosted with ❤ by GitHub

To create this handler we have to call on method on our map object. It takes two parameters, string with event type and callback function which has one parameter, evt being type of MapBrowserEvent. Basically there are two purposes of this callback, to set new value of field (also lat and lon variables) and call addPointAndFocus method. Here it is:

const addPointAndFocus = () => {
if (lat && lon) {
vectorSource
.getFeatures()
.forEach((feature) => vectorSource.removeFeature(feature));
vectorSource.addFeature(
new Feature({
geometry: new Point([lat, lon]),
name: 'Point',
})
);
map.setView(
new View({
center: [lat, lon],
zoom:
(map.getView().getZoom() || 0) > 8
? map.getView().getZoom()
: 8,
})
);
}
};

This method does three things, if variables lat and lon exists and has values then it removes all previous points. In our case there can be only one to remove, but method getFeatures returns array. Important thing to note here is that we are operating on vectorSource not vectorLayer. Next, new point feature is created with current coordinates. Lastly map view is set to have center on newly created point and increased zoom (in case if it’s smaller than 8). And now our component is almost ready, lastly we have to add a bit of styles to it. Mostly because map container has to have height set to value in pixels:

import { css } from '@emotion/css';
const styles = {
map: css`
height: 400px;
width: 100%;
`,
};
view raw styles.tsx hosted with ❤ by GitHub

One last thing to add was all styles from OpenLayers library. Here I’ve used React-Helmet package to help with that task:

import { Helmet } from 'react-helmet';
return (
<FieldContainer>
<Helmet>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/openlayers/openlayers.github.io@master/en/v6.12.0/css/ol.css"
type="text/css"
/>
</Helmet>
<FieldLabel>{props.field.label}</FieldLabel>
<div ref={mapRef} className={styles.map}></div>
</FieldContainer>
);
view raw helmet.tsx hosted with ❤ by GitHub

Ok, here is finished component, and how it looks like inside the app:


Component in action.

import React, { useEffect, useRef } from 'react';
import {
controller as StandardController,
Cell as StandardCell,
CardValue as StandardCardValue,
} from '@keystone-6/core/fields/types/text/views';
import { FieldProps } from '@keystone-6/core/types';
import { FieldContainer, FieldLabel } from '@keystone-ui/fields';
import Map from 'ol/Map';
import View from 'ol/View';
import TileLayer from 'ol/layer/Tile';
import VectorLayer from 'ol/layer/Vector';
import { TileImage, Vector as VectorSource } from 'ol/source';
import Feature from 'ol/Feature';
import Point from 'ol/geom/Point';
import { Circle as CircleStyle, Fill, Stroke, Style } from 'ol/style';
import { Helmet } from 'react-helmet';
import { css } from '@emotion/css';
const styles = {
map: css`
height: 400px;
width: 100%;
`,
};
export const Cell = StandardCell;
export const CardValue = StandardCardValue;
export const controller = StandardController;
export const Field = (props: FieldProps<typeof StandardController>) => {
const mapRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let lat: number, lon: number;
if (props.value.inner.kind === 'value') {
const coords = props.value.inner.value.split(';');
lat = Number(coords[0]);
lon = Number(coords[1]);
}
const source = new TileImage({
url: 'https://dh.gu.se/tiles/imperium/{z}/{x}/{y}.png',
});
const map = new Map({
target: mapRef.current,
layers: [
new TileLayer({ source }),
],
view: new View({
center: [4185385.9208, 3921831.0473],
zoom: 7,
}),
});
const vectorSource = new VectorSource();
const vectorLayer = new VectorLayer({
source: vectorSource,
style: new Style({
image: new CircleStyle({
radius: 5,
fill: new Fill({ color: 'rgba(255, 0, 0, 0.1)' }),
stroke: new Stroke({ color: 'red', width: 1 }),
}),
}),
});
map.addLayer(vectorLayer);
const addPointAndFocus = () => {
if (lat && lon) {
vectorSource
.getFeatures()
.forEach((feature) => vectorSource.removeFeature(feature));
vectorSource.addFeature(
new Feature({
geometry: new Point([lat, lon]),
name: 'Point',
})
);
map.setView(
new View({
center: [lat, lon],
zoom:
(map.getView().getZoom() || 0) > 8
? map.getView().getZoom()
: 8,
})
);
}
};
map.on('singleclick', (evt) => {
const [latValue, lonValue] = evt.coordinate;
lat = latValue;
lon = lonValue;
if (props.onChange) {
props.onChange({
...props.value,
inner: { kind: 'value', value: [lat, lon].join(';') },
});
}
addPointAndFocus();
});
addPointAndFocus();
}, []);
return (
<FieldContainer>
<Helmet>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/openlayers/openlayers.github.io@master/en/v6.12.0/css/ol.css"
type="text/css"
/>
</Helmet>
<FieldLabel>{props.field.label}</FieldLabel>
<div className={styles.map} ref={mapRef}></div>
</FieldContainer>
);
};

Summary

Creating new fields in Keystone maybe easier than it looks, I hope I was able to show that. At first It may look daunting, but it’s no different from creating other ordinary React components. Everything depends on our requirements, and how complicated they are. Also, libraries like OpenLayers may be a little scary at first glance, additionally quick start tutorial in documentation is focused mainly on usage in static sites (by static I mean static like ten or more years ago) what can cause some problems to users used to current approach with single page applications and gigabytes of NPM packages. But when we dig a little deeper API documentation is really great and informative.

This was second article in series about custom Keystone field components and I planed to finish it with the next one about slightly more complicated menu component, utilizing JSON field underneath. But when I was writing this one I realized that this topic is pretty niche and there’s a need for simpler, more introductory overview of Keystone.js as a CMS. So, the next one will be kind of Keystone.js v 6 101, and then we will get back to menu custom field. See you in the next one!

Image of Timescale

🚀 pgai Vectorizer: SQLAlchemy and LiteLLM Make Vector Search Simple

We built pgai Vectorizer to simplify embedding management for AI applications—without needing a separate database or complex infrastructure. Since launch, developers have created over 3,000 vectorizers on Timescale Cloud, with many more self-hosted.

Read full post →

Top comments (0)

Billboard image

The Next Generation Developer Platform

Coherence is the first Platform-as-a-Service you can control. Unlike "black-box" platforms that are opinionated about the infra you can deploy, Coherence is powered by CNC, the open-source IaC framework, which offers limitless customization.

Learn more

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay