Appearance
Geo mode + marker clustering new
ogma-playground
How to run
Before running this example, please replace YOUR_ACCESS_KEY
with your API key (which you can get from here) in package.json. Then :
sh
npm install
npm start
License (c) 2025 Linkurious SAS
ts
import Ogma, { GeoCoordinate } from '@linkurious/ogma';
import L from 'leaflet';
import supercluster from 'supercluster';
import { clusterMarker } from './custom_marker';
import 'leaflet/dist/leaflet.css';
Ogma.libraries['leaflet'] = L;
const ogma = new Ogma({
container: 'graph-container'
});
ogma.styles.addRule({
nodeAttributes: {
color: '#0093ff',
innerStroke: {
width: 0
},
text: {
content: node => node.getId(),
color: '#fff',
backgroundColor: '#333'
}
},
edgeAttributes: {
color: 'black',
width: 1
}
});
ogma.styles.setHoveredNodeAttributes({
text: { backgroundColor: '#000' }
});
const markers = L.geoJSON(null, {
pointToLayer: clusterMarker
});
const graph = await Ogma.parse.jsonFromUrl('./graph.json');
await ogma.setGraph(graph);
const center = ogma
.getNodes()
.get(0)
.getGeoCoordinates() as Required<GeoCoordinate>;
const randomGeoJSON = randomPoints(1450, 15, center);
// create cluster index
const index = new supercluster({
log: false,
// pixels
radius: 50,
extent: 512,
maxZoom: 20
}).load(randomGeoJSON.features);
await toggleGeo();
// update clusters
function update() {
const bounds = getBounds(ogma.geo.getMap()!);
const clusters = index.getClusters(bounds, ogma.geo.getZoom());
// quick and dirty update method, you can implement a viewport cache
markers.clearLayers();
markers.addData(clusters);
}
async function toggleGeo() {
await ogma.geo.toggle({
disableNodeDragging: false,
duration: 0
});
if (ogma.geo.enabled()) {
markers.addTo(ogma.geo.getMap()!);
ogma.geo.setView(48.06706753191901, 26.630859375, 7);
ogma.events.on('viewChanged', update);
}
}
function getBounds(map: L.Map) {
const bounds = map.getBounds();
return [
bounds.getWest(),
bounds.getSouth(),
bounds.getEast(),
bounds.getNorth()
];
}
function randomPoints(amount: number, size: number, center: GeoCoordinate) {
return {
features: Array.from({ length: amount }, () => {
const lat = center.latitude! + (size / 2) * (Math.random() - 0.5);
const lon = center.longitude! + size * (Math.random() - 0.5);
return {
type: 'Feature',
properties: {},
geometry: {
type: 'Point',
coordinates: [lon, lat]
}
};
})
};
}
document.getElementById('download')!.addEventListener('click', () => {
ogma.export.png({ clip: true, width: 1280, height: 720 });
});
html
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@linkurious/ogma-annotations@1.1.16/dist/style.css"
/>
<link type="text/css" rel="stylesheet" href="styles.css" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="graph-container"></div>
<div id="controls">
<button id="download" title="Download annotations"></button>
</div>
<script type="module" src="index.ts"></script>
</body>
</html>
css
html,
body {
font-family: 'IBM Plex Sans', Arial, Helvetica, sans-serif;
}
#graph-container {
top: 0;
bottom: 0;
left: 0;
right: 0;
position: absolute;
margin: 0;
overflow: hidden;
}
#controls {
position: absolute;
top: 2em;
right: 2em;
display: flex;
z-index: 1000;
}
#controls button {
width: 3em;
height: 2.5em;
margin: 0;
border: 1px solid rgba(0, 0, 0, 0.2);
background-color: #fff;
color: #444;
cursor: pointer;
background-size: 50%;
background-repeat: no-repeat;
background-position: center center;
margin-left: -1px;
border-radius: 3px;
}
#controls button:hover,
#controls button:active,
#controls button.active {
color: #222;
background-color: #f0f0f0;
border-color: rgba(0, 0, 0, 0.4);
}
#controls button:disabled {
cursor: not-allowed;
color: #999;
background-color: #f9f9f9;
border-color: rgba(0, 0, 0, 0.2);
opacity: 0.5;
}
#draw-arrow {
background-image: url('./img/icon-arrow.svg');
}
#draw-text {
background-image: url('./img/icon-textbox.svg');
}
#download {
background-image: url('./img/icon-download.svg');
}
#snapshot {
background-image: url('./img/icon-photo.svg');
}
.marker-cluster-small {
background-color: rgba(181, 226, 140, 0.6);
}
.marker-cluster-small div {
background-color: rgba(110, 204, 57, 0.6);
}
.marker-cluster-medium {
background-color: rgba(241, 211, 87, 0.6);
}
.marker-cluster-medium div {
background-color: rgba(240, 194, 12, 0.6);
}
.marker-cluster-large {
background-color: rgba(253, 156, 115, 0.6);
}
.marker-cluster-large div {
background-color: rgba(241, 128, 23, 0.6);
}
.marker-cluster {
background-clip: padding-box;
border-radius: 20px;
}
.marker-cluster div {
width: 30px;
height: 30px;
margin-left: 5px;
margin-top: 5px;
text-align: center;
border-radius: 15px;
}
.marker-cluster span {
line-height: 30px;
}
json
{
"name": "ogma-embed-ogma-playground",
"dependencies": {
"@linkurious/ogma-annotations": "1.1.23",
"d3": "7.9.0",
"@linkurious/ogma": "https://get.linkurio.us/api/get/npm/ogma/undefined/?secret=YOUR_API_KEY"
}
}
ts
import {
Marker,
MarkerOptions,
LatLng,
LatLngTuple,
divIcon,
circleMarker,
point
} from 'leaflet';
import { Feature, Point } from 'geojson';
const MARKER_STYLES = {
small: {
border: `rgba(181, 226, 140, 0.6)`,
fill: `rgba(110, 204, 57, 0.6)`
},
medium: {
border: `rgba(241, 211, 87, 0.6)`,
fill: `rgba(240, 194, 12, 0.6)`
},
large: {
border: `rgba(253, 156, 115, 0.6)`,
fill: `rgba(241, 128, 23, 0.6)`
}
};
interface CustomMarkerClass extends Marker {
export: (ctx: CanvasRenderingContext2D) => Promise<void>;
feature: Feature<
Point,
{
point_count: number;
point_count_abbreviated: string;
cluster: boolean;
}
>;
}
export const CustomMarker: {
new (latlng: LatLng | LatLngTuple, options: MarkerOptions): CustomMarkerClass;
} = Marker.extend({
export: function (ctx: CanvasRenderingContext2D) {
const latlng = this.getLatLng();
const { x, y } = ogma.geo.getMap().latLngToContainerPoint(latlng);
const count = this.feature.properties.point_count!;
const size: keyof typeof MARKER_STYLES =
count < 100 ? 'small' : count < 1000 ? 'medium' : 'large';
const { fill, border } = MARKER_STYLES[size];
ctx.save();
ctx.translate(x, y);
ctx.fillStyle = fill;
ctx.strokeStyle = border;
ctx.lineWidth = 2 * devicePixelRatio;
ctx.beginPath();
ctx.arc(0, 0, 10 * devicePixelRatio, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.fillStyle = MARKER_STYLES[size].fill;
ctx.fill();
// Draw the text
ctx.font = `${devicePixelRatio * 12}px Arial`;
ctx.fillStyle = '#222';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(this.feature.properties.point_count_abbreviated, 0, 2);
ctx.stroke();
ctx.restore();
return Promise.resolve();
}
});
export const customMarker = (
latlng: LatLng | LatLngTuple,
options: MarkerOptions
) => new CustomMarker(latlng, options);
export function clusterMarker(
feature: Feature<
Point,
{
cluster?: boolean;
point_count?: number;
point_count_abbreviated?: string;
}
>
) {
if (feature.properties!.cluster) {
const count = feature.properties.point_count!;
const size = count < 100 ? 'small' : count < 1000 ? 'medium' : 'large';
const icon = divIcon({
html: `<div><span>${feature.properties.point_count_abbreviated}</span></div>`,
className: `marker-cluster marker-cluster-${size}`,
iconSize: point(40, 40)
});
return customMarker(
feature.geometry.coordinates.slice().reverse() as LatLngTuple,
{
icon
}
);
}
return circleMarker(
feature.geometry.coordinates.slice().reverse() as LatLngTuple,
{
color: feature.properties.cluster ? 'red' : '#0093ff',
radius: feature.properties.cluster ? 10 : 5
}
);
}
json
{
"nodes": [
{
"id": "A",
"data": {
"latitude": 47.887458688596475,
"longitude": 25.46383666992188
},
"attributes": {
"radius": 10,
"x": 0,
"y": 0,
"color": "darkred"
}
},
{
"id": "B",
"data": {
"latitude": 43.905630088673846,
"longitude": 15.47723007202148
},
"attributes": {
"radius": 10,
"x": 100,
"y": 0,
"color": "darkred"
}
},
{
"id": "C",
"data": {
"latitude": 39.905630088673846,
"longitude": 17.47723007202148
},
"attributes": {
"radius": 10,
"x": 100,
"y": 0,
"color": "darkred"
}
}
],
"edges": [
{
"source": "A",
"target": "B",
"attributes": { "width": 5 }
},
{
"source": "C",
"target": "A",
"attributes": { "width": 5 }
}
]
}