diff --git a/src/c3nav/editor/forms.py b/src/c3nav/editor/forms.py
index 8bf3d432..f6f90837 100644
--- a/src/c3nav/editor/forms.py
+++ b/src/c3nav/editor/forms.py
@@ -396,7 +396,7 @@ def create_editor_form(editor_model):
'color_background', 'color_wall_fill', 'color_wall_border', 'color_door_fill', 'color_ground_fill',
'color_obstacles_default_fill', 'color_obstacles_default_border', 'stroke_color', 'stroke_width',
'stroke_opacity', 'fill_color', 'fill_opacity', 'interactive', 'point_icon', 'extra_data', 'show_label',
- 'show_geometry', 'show_label', 'show_geometry', 'default_geomtype',
+ 'show_geometry', 'show_label', 'show_geometry', 'default_geomtype', 'cluster_points',
"load_group_display", "load_group_contribute",
"altitude_quest",
]
diff --git a/src/c3nav/mapdata/migrations/0129_dataoverlay_cluster_points.py b/src/c3nav/mapdata/migrations/0129_dataoverlay_cluster_points.py
new file mode 100644
index 00000000..28f5c31d
--- /dev/null
+++ b/src/c3nav/mapdata/migrations/0129_dataoverlay_cluster_points.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.1.3 on 2024-12-26 02:54
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('mapdata', '0128_space_identifyable'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='dataoverlay',
+ name='cluster_points',
+ field=models.BooleanField(default=False, verbose_name='cluster points together when zoomed out'),
+ ),
+ ]
diff --git a/src/c3nav/mapdata/models/overlay.py b/src/c3nav/mapdata/models/overlay.py
index bdb93c89..e6b47c53 100644
--- a/src/c3nav/mapdata/models/overlay.py
+++ b/src/c3nav/mapdata/models/overlay.py
@@ -26,6 +26,8 @@ class DataOverlay(TitledMixin, AccessRestrictionMixin, models.Model):
fill_color = models.CharField(max_length=255, blank=True, null=True, verbose_name=_('default fill color'))
fill_opacity = models.FloatField(blank=True, null=True, verbose_name=_('fill opacity'))
+ cluster_points = models.BooleanField(default=False, verbose_name=_('cluster points together when zoomed out'))
+
default_geomtype = models.CharField(max_length=255, blank=True, null=True, choices=GeometryType, verbose_name=_('default geometry type'))
pull_url = models.URLField(blank=True, null=True, verbose_name=_('pull URL'))
diff --git a/src/c3nav/mapdata/schemas/models.py b/src/c3nav/mapdata/schemas/models.py
index 00567377..f829f41e 100644
--- a/src/c3nav/mapdata/schemas/models.py
+++ b/src/c3nav/mapdata/schemas/models.py
@@ -375,6 +375,7 @@ class DataOverlaySchema(TitledSchema, DjangoModelSchema):
stroke_opacity: Optional[float]
fill_color: Optional[str]
fill_opacity: Optional[float]
+ cluster_points: bool
diff --git a/src/c3nav/site/static/site/css/c3nav.scss b/src/c3nav/site/static/site/css/c3nav.scss
index 85931f3b..970d4817 100644
--- a/src/c3nav/site/static/site/css/c3nav.scss
+++ b/src/c3nav/site/static/site/css/c3nav.scss
@@ -1880,18 +1880,6 @@ blink {
}
}
-.overlay-point-icon {
- > span {
- display: inline-block;
- width: 24px;
- height: 24px;
- line-height: 24px;
- text-align: center;
- font-size: 24px;
- font-family: 'Material Symbols Outlined';
- }
-}
-
.data-overlay-popup {
.leaflet-popup-content {
margin: 0;
@@ -1944,7 +1932,8 @@ blink {
}
}
-.quest-icon {
+.symbol-icon {
+ --icon-color: var(--color-primary);
> span {
display: inline-block;
width: 30px;
@@ -1954,15 +1943,33 @@ blink {
font-size: 22px;
font-family: 'Material Symbols Outlined';
background-color: white;
- color: var(--color-primary);
+ color: var(--icon-color);
border-radius: 100%;
- box-shadow: 0 0 0 5px color-mix(in srgb, transparent, var(--color-primary) 60%);
+ box-shadow: 0 0 0 5px color-mix(in srgb, transparent, var(--icon-color) 60%);
transition: color, background-color 150ms ease-in-out;
- &:hover {
- background-color: var(--color-primary);
- color: white;
+ cursor: default;
+ }
+
+
+ &.symbol-icon-interactive {
+ > span {
+ cursor: pointer;
+
+ &:hover {
+ background-color: var(--icon-color);
+ color: white;
+ }
+ }
+ }
+
+ &.symbol-icon-empty {
+ > span {
+ width: 14px;
+ height: 14px;
+ line-height: 14px;
+ font-size: 10px;
}
}
}
diff --git a/src/c3nav/site/static/site/js/c3nav.js b/src/c3nav/site/static/site/js/c3nav.js
index 260c50cc..6324762c 100644
--- a/src/c3nav/site/static/site/js/c3nav.js
+++ b/src/c3nav/site/static/site/js/c3nav.js
@@ -2775,7 +2775,7 @@ QuestsControl = ExpandingControl.extend({
pointToLayer: (geom, latlng) => {
return L.marker(latlng, {
icon: L.divIcon({
- className: 'quest-icon',
+ className: 'symbol-icon symbol-icon-interactive',
html: `${quest_icon}`,
iconSize: [24, 24],
iconAnchor: [12, 12],
@@ -3130,6 +3130,7 @@ class DataOverlay {
this.id = options.id;
this.title = options.title;
this.group = options.group;
+ this.cluster_points = options.cluster_points;
this.default_stroke_color = options.stroke_color;
this.default_stroke_width = options.stroke_width;
this.default_stroke_opacity = options.stroke_opacity;
@@ -3144,7 +3145,11 @@ class DataOverlay {
for (const feature of features) {
const level_id = feature.level_id;
if (!(level_id in levels)) {
- levels[level_id] = L.layerGroup([]);
+ if (this.cluster_points) {
+ levels[level_id] = L.markerClusterGroup();
+ } else {
+ levels[level_id] = L.layerGroup();
+ }
}
const style = {
'color': feature.stroke_color ?? this.default_stroke_color ?? 'var(--color-map-overlay)',
@@ -3157,12 +3162,21 @@ class DataOverlay {
style,
interactive: feature.interactive,
pointToLayer: (geom, latlng) => {
+ return L.marker(latlng, {
+ title: feature.title,
+ icon: L.divIcon({
+ className: 'symbol-icon ' + (feature.point_icon ? '' : 'symbol-icon-empty ') + (feature.interactive ? 'symbol-icon-interactive' : ''),
+ html: `${feature.point_icon ?? ''}`,
+ iconSize: [24, 24],
+ iconAnchor: [12, 12],
+ })
+ });
if (feature.point_icon !== null) {
return L.marker(latlng, {
title: feature.title,
icon: L.divIcon({
- className: 'overlay-point-icon',
- html: `${feature.point_icon}`,
+ className: 'symbol-icon ' + (feature.interactive ? 'symbol-icon-interactive' : ''),
+ html: `${feature.point_icon}`,
iconSize: [24, 24],
iconAnchor: [12, 12],
})
@@ -3173,27 +3187,30 @@ class DataOverlay {
...style
});
}
+ },
+ onEachFeature: (f, layer) => {
+ if (feature.interactive) {
+ layer.bindPopup(() => {
+ let html = `
${feature.title}
`;
+ if (feature.external_url != null) {
+ html += `open external link`;
+ }
+ if (feature.extra_data != null) {
+ html += '';
+ for (const key in feature.extra_data) {
+ html += `${key} | ${feature.extra_data[key]} |
`;
+ }
+
+ html += '
';
+ }
+ return html;
+ }, {
+ className: 'data-overlay-popup'
+ });
+ }
}
});
- if (feature.interactive) {
- layer.bindPopup(() => {
- let html = `${feature.title}
`;
- if (feature.external_url != null) {
- html += `open external link`;
- }
- if (feature.extra_data != null) {
- html += '';
- for (const key in feature.extra_data) {
- html += `${key} | ${feature.extra_data[key]} |
`;
- }
- html += '
';
- }
- return html;
- }, {
- className: 'data-overlay-popup'
- });
- }
levels[level_id].addLayer(layer);
}