1import { Fill, RegularShape, Stroke, Circle, Style, Icon, Text } from 'ol/style';
2import { Registry, RegistryItem } from '@grafana/data';
3import { defaultStyleConfig, DEFAULT_SIZE, StyleConfigValues, StyleMaker } from './types';
4import { getPublicOrAbsoluteUrl } from 'app/features/dimensions';
5import tinycolor from 'tinycolor2';
6import { config } from '@grafana/runtime';
7
8interface SymbolMaker extends RegistryItem {
9  aliasIds: string[];
10  make: StyleMaker;
11}
12
13enum RegularShapeId {
14  circle = 'circle',
15  square = 'square',
16  triangle = 'triangle',
17  star = 'star',
18  cross = 'cross',
19  x = 'x',
20}
21
22const MarkerShapePath = {
23  circle: 'img/icons/marker/circle.svg',
24  square: 'img/icons/marker/square.svg',
25  triangle: 'img/icons/marker/triangle.svg',
26  star: 'img/icons/marker/star.svg',
27  cross: 'img/icons/marker/cross.svg',
28  x: 'img/icons/marker/x-mark.svg',
29};
30
31export function getFillColor(cfg: StyleConfigValues) {
32  const opacity = cfg.opacity == null ? 0.8 : cfg.opacity;
33  if (opacity === 1) {
34    return new Fill({ color: cfg.color });
35  }
36  if (opacity > 0) {
37    const color = tinycolor(cfg.color).setAlpha(opacity).toRgbString();
38    return new Fill({ color });
39  }
40  return undefined;
41}
42
43const textLabel = (cfg: StyleConfigValues) => {
44  if (!cfg.text) {
45    return undefined;
46  }
47
48  const fontFamily = config.theme2.typography.fontFamily;
49  const textConfig = {
50    ...defaultStyleConfig.textConfig,
51    ...cfg.textConfig,
52  };
53  return new Text({
54    text: cfg.text,
55    fill: new Fill({ color: cfg.color ?? defaultStyleConfig.color.fixed }),
56    font: `normal ${textConfig.fontSize}px ${fontFamily}`,
57    ...textConfig,
58  });
59};
60
61export const textMarker = (cfg: StyleConfigValues) => {
62  return new Style({
63    text: textLabel(cfg),
64  });
65};
66
67export const circleMarker = (cfg: StyleConfigValues) => {
68  return new Style({
69    image: new Circle({
70      stroke: new Stroke({ color: cfg.color, width: cfg.lineWidth ?? 1 }),
71      fill: getFillColor(cfg),
72      radius: cfg.size ?? DEFAULT_SIZE,
73    }),
74    text: textLabel(cfg),
75  });
76};
77
78export const polyStyle = (cfg: StyleConfigValues) => {
79  return new Style({
80    fill: getFillColor(cfg),
81    stroke: new Stroke({ color: cfg.color, width: cfg.lineWidth ?? 1 }),
82    text: textLabel(cfg),
83  });
84};
85
86// Square and cross
87const errorMarker = (cfg: StyleConfigValues) => {
88  const radius = cfg.size ?? DEFAULT_SIZE;
89  const stroke = new Stroke({ color: '#F00', width: 1 });
90  return [
91    new Style({
92      image: new RegularShape({
93        stroke,
94        points: 4,
95        radius,
96        angle: Math.PI / 4,
97      }),
98    }),
99    new Style({
100      image: new RegularShape({
101        stroke,
102        points: 4,
103        radius,
104        radius2: 0,
105        angle: 0,
106      }),
107    }),
108  ];
109};
110
111const makers: SymbolMaker[] = [
112  {
113    id: RegularShapeId.circle,
114    name: 'Circle',
115    aliasIds: [MarkerShapePath.circle],
116    make: circleMarker,
117  },
118  {
119    id: RegularShapeId.square,
120    name: 'Square',
121    aliasIds: [MarkerShapePath.square],
122    make: (cfg: StyleConfigValues) => {
123      const radius = cfg.size ?? DEFAULT_SIZE;
124      const rotation = cfg.rotation ?? 0;
125      return new Style({
126        image: new RegularShape({
127          stroke: new Stroke({ color: cfg.color, width: cfg.lineWidth ?? 1 }),
128          fill: getFillColor(cfg),
129          points: 4,
130          radius,
131          rotation: (rotation * Math.PI) / 180 + Math.PI / 4,
132        }),
133        text: textLabel(cfg),
134      });
135    },
136  },
137  {
138    id: RegularShapeId.triangle,
139    name: 'Triangle',
140    aliasIds: [MarkerShapePath.triangle],
141    make: (cfg: StyleConfigValues) => {
142      const radius = cfg.size ?? DEFAULT_SIZE;
143      const rotation = cfg.rotation ?? 0;
144      return new Style({
145        image: new RegularShape({
146          stroke: new Stroke({ color: cfg.color, width: cfg.lineWidth ?? 1 }),
147          fill: getFillColor(cfg),
148          points: 3,
149          radius,
150          rotation: (rotation * Math.PI) / 180,
151          angle: 0,
152        }),
153        text: textLabel(cfg),
154      });
155    },
156  },
157  {
158    id: RegularShapeId.star,
159    name: 'Star',
160    aliasIds: [MarkerShapePath.star],
161    make: (cfg: StyleConfigValues) => {
162      const radius = cfg.size ?? DEFAULT_SIZE;
163      const rotation = cfg.rotation ?? 0;
164      return new Style({
165        image: new RegularShape({
166          stroke: new Stroke({ color: cfg.color, width: cfg.lineWidth ?? 1 }),
167          fill: getFillColor(cfg),
168          points: 5,
169          radius,
170          radius2: radius * 0.4,
171          angle: 0,
172          rotation: (rotation * Math.PI) / 180,
173        }),
174        text: textLabel(cfg),
175      });
176    },
177  },
178  {
179    id: RegularShapeId.cross,
180    name: 'Cross',
181    aliasIds: [MarkerShapePath.cross],
182    make: (cfg: StyleConfigValues) => {
183      const radius = cfg.size ?? DEFAULT_SIZE;
184      const rotation = cfg.rotation ?? 0;
185      return new Style({
186        image: new RegularShape({
187          stroke: new Stroke({ color: cfg.color, width: cfg.lineWidth ?? 1 }),
188          points: 4,
189          radius,
190          radius2: 0,
191          angle: 0,
192          rotation: (rotation * Math.PI) / 180,
193        }),
194        text: textLabel(cfg),
195      });
196    },
197  },
198  {
199    id: RegularShapeId.x,
200    name: 'X',
201    aliasIds: [MarkerShapePath.x],
202    make: (cfg: StyleConfigValues) => {
203      const radius = cfg.size ?? DEFAULT_SIZE;
204      const rotation = cfg.rotation ?? 0;
205      return new Style({
206        image: new RegularShape({
207          stroke: new Stroke({ color: cfg.color, width: cfg.lineWidth ?? 1 }),
208          points: 4,
209          radius,
210          radius2: 0,
211          rotation: (rotation * Math.PI) / 180 + Math.PI / 4,
212        }),
213        text: textLabel(cfg),
214      });
215    },
216  },
217];
218
219async function prepareSVG(url: string, size?: number): Promise<string> {
220  return fetch(url, { method: 'GET' })
221    .then((res) => {
222      return res.text();
223    })
224    .then((text) => {
225      const parser = new DOMParser();
226      const doc = parser.parseFromString(text, 'image/svg+xml');
227      const svg = doc.getElementsByTagName('svg')[0];
228      if (!svg) {
229        return '';
230      }
231
232      const svgSize = size ?? 100;
233      const width = svg.getAttribute('width') ?? svgSize;
234      const height = svg.getAttribute('height') ?? svgSize;
235
236      // open layers requires a white fill becaues it uses tint to set color
237      svg.setAttribute('fill', '#fff');
238      svg.setAttribute('width', `${width}px`);
239      svg.setAttribute('height', `${height}px`);
240      const svgString = new XMLSerializer().serializeToString(svg);
241      const svgURI = encodeURIComponent(svgString);
242      return `data:image/svg+xml,${svgURI}`;
243    })
244    .catch((error) => {
245      console.error(error);
246      return '';
247    });
248}
249
250// Really just a cache for the various symbol styles
251const markerMakers = new Registry<SymbolMaker>(() => makers);
252
253export function getMarkerAsPath(shape?: string): string | undefined {
254  const marker = markerMakers.getIfExists(shape);
255  if (marker?.aliasIds?.length) {
256    return marker.aliasIds[0];
257  }
258  return undefined;
259}
260
261// Will prepare symbols as necessary
262export async function getMarkerMaker(symbol?: string, hasTextLabel?: boolean): Promise<StyleMaker> {
263  if (!symbol) {
264    return hasTextLabel ? textMarker : circleMarker;
265  }
266
267  let maker = markerMakers.getIfExists(symbol);
268  if (maker) {
269    return maker.make;
270  }
271
272  // Prepare svg as icon
273  if (symbol.endsWith('.svg')) {
274    const src = await prepareSVG(getPublicOrAbsoluteUrl(symbol));
275    maker = {
276      id: symbol,
277      name: symbol,
278      aliasIds: [],
279      make: src
280        ? (cfg: StyleConfigValues) => {
281            const radius = cfg.size ?? DEFAULT_SIZE;
282            const rotation = cfg.rotation ?? 0;
283            return [
284              new Style({
285                image: new Icon({
286                  src,
287                  color: cfg.color,
288                  opacity: cfg.opacity ?? 1,
289                  scale: (DEFAULT_SIZE + radius) / 100,
290                  rotation: (rotation * Math.PI) / 180,
291                }),
292                text: !cfg?.text ? undefined : textLabel(cfg),
293              }),
294              // transparent bounding box for featureAtPixel detection
295              new Style({
296                image: new RegularShape({
297                  fill: new Fill({ color: 'rgba(0,0,0,0)' }),
298                  points: 4,
299                  radius: cfg.size,
300                  rotation: (rotation * Math.PI) / 180 + Math.PI / 4,
301                }),
302              }),
303            ];
304          }
305        : errorMarker,
306    };
307    markerMakers.register(maker);
308    return maker.make;
309  }
310
311  // default to showing a circle
312  return errorMarker;
313}
314