diff --git a/src/c3nav/editor/api/geometries.py b/src/c3nav/editor/api/geometries.py
index f8f7aaa5..5e746680 100644
--- a/src/c3nav/editor/api/geometries.py
+++ b/src/c3nav/editor/api/geometries.py
@@ -305,8 +305,8 @@ def get_space_geometries_result(request, space_id: int, update_cache_key: str, u
space.holes.all().only('geometry', 'space'),
space.stairs.all().only('geometry', 'space'),
space.ramps.all().only('geometry', 'space'),
- space.obstacles.all().only('geometry', 'space', 'color'),
- space.lineobstacles.all().only('geometry', 'width', 'space', 'color'),
+ space.obstacles.all().only('geometry', 'space').prefetch_related('group'),
+ space.lineobstacles.all().only('geometry', 'width', 'space').prefetch_related('group'),
space.columns.all().only('geometry', 'space'),
space.altitudemarkers.all().only('geometry', 'space'),
space.wifi_measurements.all().only('geometry', 'space'),
diff --git a/src/c3nav/editor/forms.py b/src/c3nav/editor/forms.py
index 85de54bb..5b188c0f 100644
--- a/src/c3nav/editor/forms.py
+++ b/src/c3nav/editor/forms.py
@@ -9,10 +9,10 @@ from django.conf import settings
from django.core.cache import cache
from django.core.exceptions import FieldDoesNotExist
from django.core.serializers.json import DjangoJSONEncoder
-from django.db.models import Q
+from django.db.models import Q, Prefetch
from django.forms import (BooleanField, CharField, ChoiceField, DecimalField, Form, JSONField, ModelChoiceField,
ModelForm, MultipleChoiceField, Select, ValidationError)
-from django.forms.widgets import HiddenInput
+from django.forms.widgets import HiddenInput, TextInput
from django.utils.translation import get_language
from django.utils.translation import gettext_lazy as _
from shapely.geometry.geo import mapping
@@ -21,8 +21,10 @@ from pydantic import ValidationError as PydanticValidationError
from c3nav.editor.models import ChangeSet, ChangeSetUpdate
from c3nav.mapdata.fields import GeometryField
from c3nav.mapdata.forms import I18nModelFormMixin
-from c3nav.mapdata.models import GraphEdge
+from c3nav.mapdata.models import GraphEdge, LocationGroup
from c3nav.mapdata.models.access import AccessPermission
+from c3nav.mapdata.models.geometry.space import ObstacleGroup
+from c3nav.mapdata.models.theme import ThemeLocationGroupBackgroundColor, ThemeObstacleGroupBackgroundColor
from c3nav.routing.schemas import LocateRequestPeerSchema
@@ -32,6 +34,69 @@ class EditorFormBase(I18nModelFormMixin, ModelForm):
super().__init__(*args, **kwargs)
creating = not self.instance.pk
+ if self._meta.model.__name__ == 'Theme':
+ if creating:
+ locationgroup_theme_colors = {}
+ obstaclegroup_theme_colors = {}
+ else:
+ locationgroup_theme_colors = {
+ l.location_group_id: l
+ for l in self.instance.location_groups.filter(theme_id=self.instance.pk)
+ }
+ obstaclegroup_theme_colors = {
+ o.obstacle_group_id: o
+ for o in self.instance.obstacle_groups.filter(theme_id=self.instance.pk)
+ }
+
+ # TODO: can we get the model class via relationships?
+ for locationgroup in LocationGroup.objects.prefetch_related(
+ Prefetch('theme_colors', ThemeLocationGroupBackgroundColor.objects.only('fill_color'))).all():
+ related = locationgroup_theme_colors.get(locationgroup.pk, None)
+ value = related.fill_color if related is not None else None
+ other_themes_colors = {
+ l.title: l.fill_color
+ for l in locationgroup.theme_colors.all()
+ if related is None or l.pk != related.pk
+ }
+ if len(other_themes_colors) > 0:
+ other_themes_colors = json.dumps(other_themes_colors)
+ else:
+ other_themes_colors = False
+ field = CharField(max_length=32,
+ label=locationgroup.title,
+ required=False,
+ initial=value,
+ widget=TextInput(attrs={
+ 'data-themed-color': True,
+ 'data-color-base-theme': locationgroup.color if locationgroup.color else False,
+ 'data-colors-other-themes': other_themes_colors,
+ }))
+ self.fields[f'locationgroup_{locationgroup.pk}'] = field
+
+ for obstaclegroup in ObstacleGroup.objects.prefetch_related(
+ Prefetch('theme_colors', ThemeObstacleGroupBackgroundColor.objects.only('fill_color'))).all():
+ related = obstaclegroup_theme_colors.get(obstaclegroup.pk, None)
+ value = related.fill_color if related is not None else None
+ other_themes_colors = {
+ o.title: o.fill_color
+ for o in obstaclegroup.theme_colors.all()
+ if related is None or o.pk != related.pk
+ }
+ if len(other_themes_colors) > 0:
+ other_themes_colors = json.dumps(other_themes_colors)
+ else:
+ other_themes_colors = False
+ field = CharField(max_length=32,
+ label=obstaclegroup.title,
+ required=False,
+ initial=value,
+ widget=TextInput(attrs={
+ 'data-themed-color': True,
+ 'data-color-base-theme': obstaclegroup.color if obstaclegroup.color else False,
+ 'data-colors-other-themes': other_themes_colors,
+ }))
+ self.fields[f'obstaclegroup_{obstaclegroup.pk}'] = field
+
if hasattr(self.instance, 'author_id'):
if self.instance.author_id is None:
self.instance.author = request.user
@@ -309,6 +374,35 @@ class EditorFormBase(I18nModelFormMixin, ModelForm):
groups = tuple((int(val) if val.isdigit() else val) for val in groups)
self.instance.groups.set(groups)
+ if self._meta.model.__name__ == 'Theme':
+ locationgroup_colors = {l.location_group_id: l for l in self.instance.location_groups.all()}
+ for locationgroup in LocationGroup.objects.all():
+ value = self.cleaned_data[f'locationgroup_{locationgroup.pk}']
+ if value:
+ color = locationgroup_colors.get(locationgroup.pk,
+ ThemeLocationGroupBackgroundColor(theme=self.instance,
+ location_group=locationgroup))
+ color.fill_color = value
+ color.save()
+ else:
+ color = locationgroup_colors.get(locationgroup.pk, None)
+ if color is not None:
+ color.delete()
+
+ obstaclegroup_colors = {o.obstacle_group_id: o for o in self.instance.obstacle_groups.all()}
+ for obstaclegroup in ObstacleGroup.objects.all():
+ value = self.cleaned_data[f'obstaclegroup_{obstaclegroup.pk}']
+ if value:
+ color = obstaclegroup_colors.get(obstaclegroup.pk,
+ ThemeObstacleGroupBackgroundColor(theme=self.instance,
+ obstacle_group=obstaclegroup))
+ color.fill_color = value
+ color.save()
+ else:
+ color = obstaclegroup_colors.get(obstaclegroup.pk)
+ if color is not None:
+ color.delete()
+
def create_editor_form(editor_model):
possible_fields = ['slug', 'name', 'title', 'title_plural', 'help_text', 'position_secret',
diff --git a/src/c3nav/editor/static/editor/css/editor.scss b/src/c3nav/editor/static/editor/css/editor.scss
index 38508eec..624c66e1 100644
--- a/src/c3nav/editor/static/editor/css/editor.scss
+++ b/src/c3nav/editor/static/editor/css/editor.scss
@@ -459,3 +459,31 @@ body:not(.mobileclient) .wificollector .btn {
.wificollector table tr td {
color: #666666;
}
+
+
+.theme-editor-filter {
+ body > & {
+ display: none;
+ }
+
+ border-top: 1px solid #e7e7e7;
+ padding-top: 12px;
+ margin-bottom: 12px;
+}
+
+body > .theme-color-info {
+ display: none;
+}
+
+label.theme-color-label {
+ display: block;
+ > .theme-color-info {
+ float: right;
+ text-decoration: underline black dotted;
+ cursor: help;
+ }
+}
+
+.theme-color-hidden {
+ display: none;
+}
\ No newline at end of file
diff --git a/src/c3nav/editor/static/editor/js/editor.js b/src/c3nav/editor/static/editor/js/editor.js
index 8b5652e8..11ab0953 100644
--- a/src/c3nav/editor/static/editor/js/editor.js
+++ b/src/c3nav/editor/static/editor/js/editor.js
@@ -72,6 +72,7 @@ editor = {
editor.init_geometries();
editor.init_wificollector();
+ editor.sidebar_content_loaded();
},
_inform_mobile_client: function(elem) {
if (!window.mobileclient || !elem.length) return;
@@ -199,6 +200,67 @@ editor = {
}
level_control.current_id = parseInt(level_list.attr('data-current-id'));
},
+
+ sidebar_content_loaded: function() {
+ if (document.querySelector('#sidebar [data-themed-color]')) {
+ editor.theme_editor_loaded();
+ }
+ },
+ theme_editor_loaded: function() {
+ const filter_show_all = () => {
+ for (const input of document.querySelectorAll('#sidebar [data-themed-color]')) {
+ input.parentElement.classList.remove('theme-color-hidden');
+ }
+ };
+ const filter_show_base = () => {
+ for (const input of document.querySelectorAll('#sidebar [data-themed-color]')) {
+ input.parentElement.classList.toggle('theme-color-hidden',
+ !('colorBaseTheme' in input.dataset));
+ }
+ };
+ const filter_show_any = () => {
+ for (const input of document.querySelectorAll('#sidebar [data-themed-color]')) {
+ input.parentElement.classList.toggle('theme-color-hidden',
+ !('colorBaseTheme' in input.dataset || 'colorsOtherThemes' in input.dataset));
+ }
+ };
+
+ const filterButtons = document.querySelector('body>.theme-editor-filter').cloneNode(true);
+ const first_color_input = document.querySelector('#sidebar [data-themed-color]:first-of-type');
+ first_color_input.parentElement.before(filterButtons);
+ filterButtons.addEventListener('click', e => {
+ const btn = e.target;
+ if (btn.classList.contains('active')) return;
+ for (const b of filterButtons.querySelectorAll('button')) {
+ b.classList.remove('active');
+ }
+ btn.classList.add('active');
+ if ('all' in btn.dataset) filter_show_all();
+ else if ('baseTheme' in btn.dataset) filter_show_base();
+ else if ('anyTheme' in btn.dataset) filter_show_any();
+ });
+
+ const baseInfoElement = document.querySelector('body>.theme-color-info');
+
+ for (const color_input of document.querySelectorAll('#sidebar [data-themed-color]')) {
+ let colors = {};
+ if ('colorBaseTheme' in color_input.dataset) {
+ colors.base = color_input.dataset.colorBaseTheme;
+ }
+ if ('colorsOtherThemes' in color_input.dataset) {
+ const other_themes = JSON.parse(color_input.dataset.colorsOtherThemes);
+ colors = {...colors, ...other_themes};
+ }
+ const titleStr = Object.entries(colors).map(([theme, color]) => `${theme}: ${color}`).join('
');
+ if (!titleStr) continue;
+ const infoElement = baseInfoElement.cloneNode(true);
+ infoElement.title = titleStr;
+ const label = color_input.previousElementSibling;
+ label.classList.add('theme-color-label');
+ label.appendChild(infoElement);
+ }
+ },
+
_in_modal: false,
_sidebar_loaded: function(data) {
// sidebar was loaded. load the content. check if there are any redirects. call _check_start_editing.
@@ -206,6 +268,7 @@ editor = {
if (data !== undefined) {
var doc = (new DOMParser).parseFromString(data, 'text/html');
content[0].replaceChildren(...doc.body.children);
+ editor.sidebar_content_loaded();
}
var redirect = content.find('span[data-redirect]');
diff --git a/src/c3nav/editor/templates/editor/base.html b/src/c3nav/editor/templates/editor/base.html
index 74fffe23..38cd619f 100644
--- a/src/c3nav/editor/templates/editor/base.html
+++ b/src/c3nav/editor/templates/editor/base.html
@@ -73,6 +73,15 @@
+
+
+
+
+
+
+
+ {% trans 'Other theme colors' %}
+
{% include 'site/fragment_fakemobileclient.html' %}
{% compress js %}
diff --git a/src/c3nav/editor/urls.py b/src/c3nav/editor/urls.py
index 84fa8fb9..dd47606b 100644
--- a/src/c3nav/editor/urls.py
+++ b/src/c3nav/editor/urls.py
@@ -57,6 +57,7 @@ urlpatterns = [
urlpatterns.extend(add_editor_urls('Level', with_list=False, explicit_edit=True))
urlpatterns.extend(add_editor_urls('LocationGroupCategory'))
urlpatterns.extend(add_editor_urls('LocationGroup'))
+urlpatterns.extend(add_editor_urls('ObstacleGroup'))
urlpatterns.extend(add_editor_urls('DynamicLocation'))
urlpatterns.extend(add_editor_urls('WayType'))
urlpatterns.extend(add_editor_urls('GroundAltitude'))
@@ -64,6 +65,7 @@ urlpatterns.extend(add_editor_urls('AccessRestriction'))
urlpatterns.extend(add_editor_urls('AccessRestrictionGroup'))
urlpatterns.extend(add_editor_urls('Source'))
urlpatterns.extend(add_editor_urls('LabelSettings'))
+urlpatterns.extend(add_editor_urls('Theme'))
urlpatterns.extend(add_editor_urls('Building', 'Level'))
urlpatterns.extend(add_editor_urls('Space', 'Level', explicit_edit=True))
urlpatterns.extend(add_editor_urls('Door', 'Level'))
diff --git a/src/c3nav/editor/views/edit.py b/src/c3nav/editor/views/edit.py
index 887626e9..08a55edf 100644
--- a/src/c3nav/editor/views/edit.py
+++ b/src/c3nav/editor/views/edit.py
@@ -51,6 +51,7 @@ def main_index(request):
'child_models': [
child_model(request, 'LocationGroupCategory'),
child_model(request, 'LocationGroup'),
+ child_model(request, 'ObstacleGroup'),
child_model(request, 'GroundAltitude'),
child_model(request, 'DynamicLocation'),
child_model(request, 'WayType'),
@@ -58,6 +59,7 @@ def main_index(request):
child_model(request, 'AccessRestrictionGroup'),
child_model(request, 'LabelSettings'),
child_model(request, 'Source'),
+ child_model(request, 'Theme'),
],
}, fields=('can_create_level', 'child_models'))
diff --git a/src/c3nav/mapdata/migrations/0099_theming.py b/src/c3nav/mapdata/migrations/0099_theming.py
new file mode 100644
index 00000000..8701f826
--- /dev/null
+++ b/src/c3nav/mapdata/migrations/0099_theming.py
@@ -0,0 +1,79 @@
+# Generated by Django 4.2.7 on 2024-01-02 19:57
+
+import c3nav.mapdata.fields
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('mapdata', '0098_report_import_tag'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='ObstacleGroup',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('title', c3nav.mapdata.fields.I18nField(blank=True, fallback_any=True, fallback_value='{model} {pk}', plural_name='titles', verbose_name='Title')),
+ ('color', models.CharField(blank=True, max_length=32, null=True)),
+ ],
+ options={
+ 'verbose_name': 'Obstacle Group',
+ 'verbose_name_plural': 'Obstacle Groups',
+ 'default_related_name': 'groups',
+ },
+ ),
+ migrations.CreateModel(
+ name='Theme',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('title', c3nav.mapdata.fields.I18nField(blank=True, fallback_any=True, fallback_value='{model} {pk}', plural_name='titles', verbose_name='Title')),
+ ('description', models.TextField()),
+ ('color_background', models.CharField(max_length=32, verbose_name='background color')),
+ ('color_wall_fill', models.CharField(max_length=32, verbose_name='wall fill color')),
+ ('color_wall_border', models.CharField(max_length=32, verbose_name='wall border color')),
+ ('color_door_fill', models.CharField(max_length=32, verbose_name='door fill color')),
+ ('color_ground_fill', models.CharField(max_length=32, verbose_name='ground fill color')),
+ ('color_obstacles_default_fill', models.CharField(max_length=32, verbose_name='default fill color for obstacles')),
+ ('color_obstacles_default_border', models.CharField(max_length=32, verbose_name='default border color for obstacles')),
+ ('last_updated', models.DateTimeField(auto_now=True)),
+ ],
+ options={
+ 'verbose_name': 'Theme',
+ 'verbose_name_plural': 'Themes',
+ 'default_related_name': 'themes',
+ },
+ ),
+ migrations.CreateModel(
+ name='ThemeObstacleGroupBackgroundColor',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('fill_color', models.CharField(blank=True, max_length=32, null=True)),
+ ('border_color', models.CharField(blank=True, max_length=32, null=True)),
+ ('obstacle_group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='theme_colors', to='mapdata.obstaclegroup')),
+ ('theme', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='obstacle_groups', to='mapdata.theme')),
+ ],
+ ),
+ migrations.CreateModel(
+ name='ThemeLocationGroupBackgroundColor',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('fill_color', models.CharField(blank=True, max_length=32, null=True)),
+ ('border_color', models.CharField(blank=True, max_length=32, null=True)),
+ ('location_group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='theme_colors', to='mapdata.locationgroup')),
+ ('theme', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='location_groups', to='mapdata.theme')),
+ ],
+ ),
+ migrations.AddField(
+ model_name='lineobstacle',
+ name='group',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='mapdata.obstaclegroup'),
+ ),
+ migrations.AddField(
+ model_name='obstacle',
+ name='group',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='mapdata.obstaclegroup'),
+ ),
+ ]
diff --git a/src/c3nav/mapdata/migrations/0100_obstaclegroup_color_data.py b/src/c3nav/mapdata/migrations/0100_obstaclegroup_color_data.py
new file mode 100644
index 00000000..2872f3d3
--- /dev/null
+++ b/src/c3nav/mapdata/migrations/0100_obstaclegroup_color_data.py
@@ -0,0 +1,56 @@
+# Generated by Django 4.2.7 on 2024-01-05 10:27
+
+from django.db import migrations
+
+sql_up = '''
+-- create an obstacle group for every distinct obstacle color
+insert into mapdata_obstaclegroup (color, titles)
+select color as color,
+ json_build_object('en', concat('Color ', color)) as titles
+from
+ (select distinct color
+ from mapdata_obstacle
+ where color is not null
+ union
+ select distinct color
+ from mapdata_lineobstacle
+ where color is not null) as obstacle_colors;
+
+-- set the groups for colored obstacles to the previously created group with that color
+update mapdata_obstacle as o
+set group_id = g.id
+from mapdata_obstaclegroup g
+where g.color = o.color;
+
+update mapdata_lineobstacle as o
+set group_id = g.id
+from mapdata_obstaclegroup g
+where g.color = o.color;
+'''
+
+sql_down = '''
+-- set obstacle color from associated group color and remove group
+update mapdata_obstacle as o
+set color = g.color, group_id = null
+from mapdata_obstaclegroup g
+where g.id = o.group_id;
+
+update mapdata_lineobstacle as o
+set color = g.color, group_id = null
+from mapdata_obstaclegroup g
+where g.id = o.group_id;
+
+-- delete groups
+delete from mapdata_obstaclegroup where true;
+'''
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('mapdata', '0099_theming'),
+ ]
+
+ operations = [
+ migrations.RunSQL(sql_up, sql_down)
+ ]
diff --git a/src/c3nav/mapdata/migrations/0101_remove_obstacle_color.py b/src/c3nav/mapdata/migrations/0101_remove_obstacle_color.py
new file mode 100644
index 00000000..a32805ac
--- /dev/null
+++ b/src/c3nav/mapdata/migrations/0101_remove_obstacle_color.py
@@ -0,0 +1,17 @@
+# Generated by Django 4.2.7 on 2024-01-05 11:16
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('mapdata', '0100_obstaclegroup_color_data'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='obstacle',
+ name='color',
+ )
+ ]
diff --git a/src/c3nav/mapdata/models/geometry/level.py b/src/c3nav/mapdata/models/geometry/level.py
index 950b08ca..9afb4bbf 100644
--- a/src/c3nav/mapdata/models/geometry/level.py
+++ b/src/c3nav/mapdata/models/geometry/level.py
@@ -39,7 +39,8 @@ class LevelGeometryMixin(GeometryMixin):
result = super().get_geojson_properties(*args, **kwargs)
result['level'] = self.level_id
if hasattr(self, 'get_color'):
- color = self.get_color(instance=instance)
+ from c3nav.mapdata.render.theme import ColorManager
+ color = self.get_color(ColorManager.for_theme(None), instance=instance)
if color:
result['color'] = color
if hasattr(self, 'opacity'):
diff --git a/src/c3nav/mapdata/models/geometry/space.py b/src/c3nav/mapdata/models/geometry/space.py
index 5d54e167..02386d3f 100644
--- a/src/c3nav/mapdata/models/geometry/space.py
+++ b/src/c3nav/mapdata/models/geometry/space.py
@@ -14,7 +14,7 @@ from c3nav.mapdata.fields import GeometryField, I18nField
from c3nav.mapdata.grid import grid
from c3nav.mapdata.models import Space
from c3nav.mapdata.models.access import AccessRestrictionMixin
-from c3nav.mapdata.models.base import SerializableMixin
+from c3nav.mapdata.models.base import SerializableMixin, TitledMixin
from c3nav.mapdata.models.geometry.base import GeometryMixin
from c3nav.mapdata.models.locations import SpecificLocation
from c3nav.mapdata.utils.cache.changes import changed_geometries
@@ -38,7 +38,8 @@ class SpaceGeometryMixin(GeometryMixin):
def get_geojson_properties(self, *args, instance=None, **kwargs) -> dict:
result = super().get_geojson_properties(*args, **kwargs)
if hasattr(self, 'get_color'):
- color = self.get_color(instance=instance)
+ from c3nav.mapdata.render.theme import ColorManager
+ color = self.get_color(ColorManager.for_theme(None), instance=instance)
if color:
result['color'] = color
if hasattr(self, 'opacity'):
@@ -175,16 +176,42 @@ class Ramp(SpaceGeometryMixin, models.Model):
default_related_name = 'ramps'
+class ObstacleGroup(TitledMixin, models.Model):
+ color = models.CharField(max_length=32, null=True, blank=True)
+
+ class Meta:
+ verbose_name = _('Obstacle Group')
+ verbose_name_plural = _('Obstacle Groups')
+ default_related_name = 'groups'
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.orig_color = self.color
+
+ def save(self, *args, **kwargs):
+ if self.pk and (self.orig_color != self.color):
+ self.register_changed_geometries()
+ super().save(*args, **kwargs)
+
+ def delete(self, *args, **kwargs):
+ self.register_changed_geometries()
+ super().delete(*args, **kwargs)
+
+ def register_changed_geometries(self, do_query=True):
+ for obj in self.obstacles.select_related('space'):
+ obj.register_change(force=True)
+
+
class Obstacle(SpaceGeometryMixin, models.Model):
"""
An obstacle
"""
+ group = models.ForeignKey(ObstacleGroup, null=True, blank=True, on_delete=models.SET_NULL)
geometry = GeometryField('polygon')
height = models.DecimalField(_('height'), max_digits=6, decimal_places=2, default=0.8,
validators=[MinValueValidator(Decimal('0'))])
altitude = models.DecimalField(_('altitude above ground'), max_digits=6, decimal_places=2, default=0,
validators=[MinValueValidator(Decimal('0'))])
- color = models.CharField(null=True, blank=True, max_length=32, verbose_name=_('color (optional)'))
class Meta:
verbose_name = _('Obstacle')
@@ -194,22 +221,31 @@ class Obstacle(SpaceGeometryMixin, models.Model):
def get_geojson_properties(self, *args, instance=None, **kwargs) -> dict:
result = super().get_geojson_properties(*args, **kwargs)
- if self.color:
- result['color'] = self.color
+ from c3nav.mapdata.render.theme import ColorManager
+ color = self.get_color(ColorManager.for_theme(None), instance=instance)
+ if color:
+ result['color'] = color
return result
def _serialize(self, geometry=True, **kwargs):
result = super()._serialize(geometry=geometry, **kwargs)
result['height'] = float(str(self.height))
result['altitude'] = float(str(self.altitude))
- result['color'] = self.color
+ from c3nav.mapdata.render.theme import ColorManager
+ result['color'] = self.get_color(ColorManager.for_theme(None))
return result
+ def get_color(self, color_manager: 'ThemeColorManager', instance=None):
+ if instance is None:
+ instance = self
+ return color_manager.obstaclegroup_fill_color(instance.group) if instance.group is not None else color_manager.obstacles_default_fill
+
class LineObstacle(SpaceGeometryMixin, models.Model):
"""
An obstacle that is a line with a specific width
"""
+ group = models.ForeignKey(ObstacleGroup, null=True, blank=True, on_delete=models.SET_NULL)
geometry = GeometryField('linestring')
width = models.DecimalField(_('width'), max_digits=4, decimal_places=2, default=0.15)
height = models.DecimalField(_('height'), max_digits=6, decimal_places=2, default=0.8,
@@ -217,6 +253,7 @@ class LineObstacle(SpaceGeometryMixin, models.Model):
altitude = models.DecimalField(_('altitude above ground'), max_digits=6, decimal_places=2, default=0,
validators=[MinValueValidator(Decimal('0'))])
color = models.CharField(null=True, blank=True, max_length=32, verbose_name=_('color (optional)'))
+ # TODO: migrate away from color same as for Obstacle
class Meta:
verbose_name = _('Line Obstacle')
@@ -226,8 +263,10 @@ class LineObstacle(SpaceGeometryMixin, models.Model):
def get_geojson_properties(self, *args, instance=None, **kwargs) -> dict:
result = super().get_geojson_properties(*args, **kwargs)
- if self.color:
- result['color'] = self.color
+ from c3nav.mapdata.render.theme import ColorManager
+ color = self.get_color(ColorManager.for_theme(None), instance=instance)
+ if color:
+ result['color'] = color
return result
def _serialize(self, geometry=True, **kwargs):
@@ -235,11 +274,18 @@ class LineObstacle(SpaceGeometryMixin, models.Model):
result['width'] = float(str(self.width))
result['height'] = float(str(self.height))
result['altitude'] = float(str(self.altitude))
- result['color'] = self.color
+ from c3nav.mapdata.render.theme import ColorManager
+ result['color'] = self.get_color(ColorManager.for_theme(None))
if geometry:
result['buffered_geometry'] = format_geojson(mapping(self.buffered_geometry))
return result
+ def get_color(self, color_manager: 'ThemeColorManager', instance=None):
+ if instance is None:
+ instance = self
+ # TODO: should line obstacles use border color?
+ return color_manager.obstaclegroup_fill_color(instance.group) if instance.group is not None else color_manager.obstacles_default_fill
+
@property
def buffered_geometry(self):
return self.geometry.buffer(float(self.width / 2), join_style=JOIN_STYLE.mitre, cap_style=CAP_STYLE.flat)
diff --git a/src/c3nav/mapdata/models/locations.py b/src/c3nav/mapdata/models/locations.py
index 70999e8f..b2576e43 100644
--- a/src/c3nav/mapdata/models/locations.py
+++ b/src/c3nav/mapdata/models/locations.py
@@ -155,18 +155,19 @@ class Location(LocationSlug, AccessRestrictionMixin, TitledMixin, models.Model):
def grid_square(self):
return None
- def get_color(self, instance=None):
+ def get_color(self, color_manager: 'ThemeColorManager', instance=None):
# dont filter in the query here so prefetch_related works
- result = self.get_color_sorted(instance)
+ result = self.get_color_sorted(color_manager, instance)
return None if result is None else result[1]
- def get_color_sorted(self, instance=None):
+ def get_color_sorted(self, color_manager: 'ThemeColorManager', instance=None):
# dont filter in the query here so prefetch_related works
if instance is None:
instance = self
for group in instance.groups.all():
- if group.color and getattr(group.category, 'allow_'+self.__class__._meta.default_related_name):
- return (0, group.category.priority, group.hierarchy, group.priority), group.color
+ color = color_manager.locationgroup_fill_color(group)
+ if color and getattr(group.category, 'allow_'+self.__class__._meta.default_related_name):
+ return (0, group.category.priority, group.hierarchy, group.priority), color
return None
def get_icon(self):
diff --git a/src/c3nav/mapdata/models/theme.py b/src/c3nav/mapdata/models/theme.py
new file mode 100644
index 00000000..ea6246b0
--- /dev/null
+++ b/src/c3nav/mapdata/models/theme.py
@@ -0,0 +1,58 @@
+from django.db import models
+from django.utils.translation import gettext_lazy as _
+
+from c3nav.mapdata.models import LocationGroup
+from c3nav.mapdata.models.base import TitledMixin
+from c3nav.mapdata.models.geometry.space import ObstacleGroup
+
+
+class Theme(TitledMixin, models.Model):
+ """
+ A theme
+ """
+ # TODO: when a theme base colors change we need to bust the cache somehow
+ description = models.TextField()
+ color_background = models.CharField(max_length=32, verbose_name=_('background color'))
+ color_wall_fill = models.CharField(max_length=32, verbose_name=_('wall fill color'))
+ color_wall_border = models.CharField(max_length=32, verbose_name=_('wall border color'))
+ color_door_fill = models.CharField(max_length=32, verbose_name=_('door fill color'))
+ color_ground_fill = models.CharField(max_length=32, verbose_name=_('ground fill color'))
+ color_obstacles_default_fill = models.CharField(max_length=32, verbose_name=_('default fill color for obstacles'))
+ color_obstacles_default_border = models.CharField(max_length=32,
+ verbose_name=_('default border color for obstacles'))
+
+ last_updated = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ verbose_name = _('Theme')
+ verbose_name_plural = _('Themes')
+ default_related_name = 'themes'
+
+
+class ThemeLocationGroupBackgroundColor(models.Model):
+ """
+ A background color for a LocationGroup in a theme
+ """
+ theme = models.ForeignKey(Theme, on_delete=models.CASCADE, related_name="location_groups")
+ location_group = models.ForeignKey(LocationGroup, on_delete=models.SET_NULL, null=True, blank=True,
+ related_name="theme_colors")
+ fill_color = models.CharField(max_length=32, null=True, blank=True)
+ border_color = models.CharField(max_length=32, null=True, blank=True)
+
+ def save(self, *args, **kwargs):
+ self.location_group.register_changed_geometries()
+ super().save(*args, **kwargs)
+
+ def delete(self, *args, **kwargs):
+ self.location_group.register_changed_geometries()
+ super().delete(*args, **kwargs)
+
+
+class ThemeObstacleGroupBackgroundColor(models.Model):
+ """
+ A background color for an ObstacleGroup in a theme
+ """
+ theme = models.ForeignKey(Theme, on_delete=models.CASCADE, related_name="obstacle_groups")
+ obstacle_group = models.ForeignKey(ObstacleGroup, on_delete=models.SET_NULL, null=True, blank=True, related_name="theme_colors")
+ fill_color = models.CharField(max_length=32, null=True, blank=True)
+ border_color = models.CharField(max_length=32, null=True, blank=True)
diff --git a/src/c3nav/mapdata/render/geometry/level.py b/src/c3nav/mapdata/render/geometry/level.py
index 1fd7a94f..c21429cb 100644
--- a/src/c3nav/mapdata/render/geometry/level.py
+++ b/src/c3nav/mapdata/render/geometry/level.py
@@ -63,7 +63,7 @@ class LevelGeometries:
return '' % (self.short_label, self.pk)
@classmethod
- def build_for_level(cls, level, altitudeareas_above):
+ def build_for_level(cls, level, color_manager: 'ThemeColorManager', altitudeareas_above):
geoms = LevelGeometries()
buildings_geom = unary_union([unwrap_geom(b.geometry) for b in level.buildings.all()])
geoms.buildings = buildings_geom
@@ -128,7 +128,7 @@ class LevelGeometries:
buffered.difference(buildings_geom)
)
- colors.setdefault(space.get_color_sorted(), {}).setdefault(access_restriction, []).append(
+ colors.setdefault(space.get_color_sorted(color_manager), {}).setdefault(access_restriction, []).append(
unwrap_geom(space.geometry)
)
@@ -137,7 +137,7 @@ class LevelGeometries:
area.geometry = area.geometry.intersection(unwrap_geom(space.walkable_geom))
if access_restriction is not None:
access_restriction_affected.setdefault(access_restriction, []).append(area.geometry)
- colors.setdefault(area.get_color_sorted(), {}).setdefault(access_restriction, []).append(area.geometry)
+ colors.setdefault(area.get_color_sorted(color_manager), {}).setdefault(access_restriction, []).append(area.geometry)
for column in space.columns.all():
access_restriction = column.access_restriction_id
@@ -156,14 +156,14 @@ class LevelGeometries:
continue
obstacles.setdefault(
int((obstacle.height+obstacle.altitude)*1000), {}
- ).setdefault(obstacle.color, []).append(
+ ).setdefault(obstacle.get_color(color_manager), []).append(
obstacle.geometry.intersection(unwrap_geom(space.walkable_geom))
)
for lineobstacle in space.lineobstacles.all():
if not lineobstacle.height:
continue
- obstacles.setdefault(int(lineobstacle.height*1000), {}).setdefault(lineobstacle.color, []).append(
+ obstacles.setdefault(int(lineobstacle.height*1000), {}).setdefault(lineobstacle.get_color(color_manager), []).append(
lineobstacle.buffered_geometry.intersection(unwrap_geom(space.walkable_geom))
)
diff --git a/src/c3nav/mapdata/render/renderdata.py b/src/c3nav/mapdata/render/renderdata.py
index f818c08a..cbe928a2 100644
--- a/src/c3nav/mapdata/render/renderdata.py
+++ b/src/c3nav/mapdata/render/renderdata.py
@@ -14,6 +14,7 @@ from shapely.ops import unary_union
from shapely.prepared import PreparedGeometry
from c3nav.mapdata.models import Level, MapUpdate, Source
+from c3nav.mapdata.models.theme import Theme
from c3nav.mapdata.render.geometry import AltitudeAreaGeometries, LevelGeometries
from c3nav.mapdata.utils.cache import AccessRestrictionAffected, MapHistory
from c3nav.mapdata.utils.cache.package import CachePackage
@@ -66,290 +67,300 @@ class LevelRenderData:
# todo: we should check that levels on top come before their levels as they should
- """
- first pass in reverse to collect some data that we need later
- """
- # level geometry for every single level
- single_level_geoms: dict[int, LevelGeometries] = {}
- # interpolator are used to create the 3d mesh
- interpolators = {}
- last_interpolator: NearestNDInterpolator | None = None
- # altitudeareas of levels on top are are collected on the way down to supply to the levelgeometries builder
- altitudeareas_above = [] # todo: typing
- for render_level in reversed(levels):
- # build level geometry for every single level
- single_level_geoms[render_level.pk] = LevelGeometries.build_for_level(render_level, altitudeareas_above)
-
- # ignore intermediate levels in this pass
- if render_level.on_top_of_id is not None:
- # todo: shouldn't this be cleared or something?
- altitudeareas_above.extend(single_level_geoms[render_level.pk].altitudeareas)
- altitudeareas_above.sort(key=operator.attrgetter('altitude'))
- continue
-
- # create interpolator to create the pieces that fit multiple 3d layers together
- if last_interpolator is not None:
- interpolators[render_level.pk] = last_interpolator
-
- coords = deque()
- values = deque()
- for area in single_level_geoms[render_level.pk].altitudeareas:
- new_coords = np.vstack(tuple(np.array(ring.coords) for ring in get_rings(area.geometry)))
- coords.append(new_coords)
- values.append(np.full((new_coords.shape[0], 1), fill_value=area.altitude))
-
- if coords:
- last_interpolator = NearestNDInterpolator(np.vstack(coords), np.vstack(values))
- else:
- last_interpolator = NearestNDInterpolator(np.array([[0, 0]]),
- np.array([float(render_level.base_altitude)]))
-
- """
- second pass, forward to create the LevelRenderData for each level
- """
- for render_level in levels:
- # we don't create render data for on_top_of levels
- if render_level.on_top_of_id is not None:
- continue
-
- map_history = MapHistory.open_level(render_level.pk, 'base')
-
- # collect potentially relevant levels for rendering this level
- # these are all levels that are on_top_of this level or below this level
- relevant_levels = tuple(
- sublevel for sublevel in levels
- if sublevel.on_top_of_id == render_level.pk or sublevel.base_altitude <= render_level.base_altitude
- )
+ themes = [None, *Theme.objects.values_list('pk', flat=True)]
+ from c3nav.mapdata.render.theme import ColorManager
+ for theme in themes:
+ color_manager = ColorManager.for_theme(theme)
"""
- choose a crop area for each level. non-intermediate levels (not on_top_of) below the one that we are
- currently rendering will be cropped to only render content that is visible through holes indoors in the
- levels above them.
+ first pass in reverse to collect some data that we need later
"""
- # area to crop each level to, by id
- level_crop_to: dict[int, Cropper] = {}
- # current remaining area that we're cropping to – None means no cropping
- crop_to = None
- primary_level_count = 0
- main_level_passed = 0
- lowest_important_level = None
- last_lower_bound = None
- for level in reversed(relevant_levels): # reversed means we are going down
- geoms = single_level_geoms[level.pk]
+ # level geometry for every single level
+ single_level_geoms: dict[int, LevelGeometries] = {}
+ # interpolator are used to create the 3d mesh
+ interpolators = {}
+ last_interpolator: NearestNDInterpolator | None = None
+ # altitudeareas of levels on top are are collected on the way down to supply to the levelgeometries builder
+ altitudeareas_above = [] # todo: typing
+ for render_level in reversed(levels):
+ # build level geometry for every single level
+ single_level_geoms[render_level.pk] = LevelGeometries.build_for_level(render_level, color_manager, altitudeareas_above)
- if geoms.holes is not None:
- primary_level_count += 1
+ # ignore intermediate levels in this pass
+ if render_level.on_top_of_id is not None:
+ # todo: shouldn't this be cleared or something?
+ altitudeareas_above.extend(single_level_geoms[render_level.pk].altitudeareas)
+ altitudeareas_above.sort(key=operator.attrgetter('altitude'))
+ continue
- # get lowest intermediate level directly below main level
- if not main_level_passed:
- if geoms.pk == render_level.pk:
- main_level_passed = 1
+ # create interpolator to create the pieces that fit multiple 3d layers together
+ if last_interpolator is not None:
+ interpolators[render_level.pk] = last_interpolator
+
+ coords = deque()
+ values = deque()
+ for area in single_level_geoms[render_level.pk].altitudeareas:
+ new_coords = np.vstack(tuple(np.array(ring.coords) for ring in get_rings(area.geometry)))
+ coords.append(new_coords)
+ values.append(np.full((new_coords.shape[0], 1), fill_value=area.altitude))
+
+ if coords:
+ last_interpolator = NearestNDInterpolator(np.vstack(coords), np.vstack(values))
else:
- if not level.on_top_of_id:
- main_level_passed += 1
- if main_level_passed < 2:
- lowest_important_level = level
+ last_interpolator = NearestNDInterpolator(np.array([[0, 0]]),
+ np.array([float(render_level.base_altitude)]))
- # make upper bounds
- if geoms.on_top_of_id is None:
- if last_lower_bound is None:
- geoms.upper_bound = geoms.max_altitude+geoms.max_height
- else:
- geoms.upper_bound = last_lower_bound
- last_lower_bound = geoms.lower_bound
-
- # set crop area if we area on the second primary layer from top or below
- level_crop_to[level.pk] = Cropper(crop_to if primary_level_count > 1 else None)
-
- if geoms.holes is not None: # there area holes on this area
- if crop_to is None:
- crop_to = geoms.holes
- else:
- crop_to = crop_to.intersection(geoms.holes)
-
- if crop_to.is_empty:
- break
-
- render_data = LevelRenderData(
- base_altitude=render_level.base_altitude,
- lowest_important_level=lowest_important_level.pk,
- )
- access_restriction_affected = {}
-
- # go through sublevels, get their level geometries and crop them
- lowest_important_level_passed = False
- for level in relevant_levels:
- try:
- crop_to = level_crop_to[level.pk]
- except KeyError:
+ """
+ second pass, forward to create the LevelRenderData for each level
+ """
+ for render_level in levels:
+ # we don't create render data for on_top_of levels
+ if render_level.on_top_of_id is not None:
continue
- old_geoms = single_level_geoms[level.pk]
+ map_history = MapHistory.open_level(render_level.pk, 'base')
- if render_data.lowest_important_level == level.pk:
- lowest_important_level_passed = True
+ # collect potentially relevant levels for rendering this level
+ # these are all levels that are on_top_of this level or below this level
+ relevant_levels = tuple(
+ sublevel for sublevel in levels
+ if sublevel.on_top_of_id == render_level.pk or sublevel.base_altitude <= render_level.base_altitude
+ )
- if old_geoms.holes and render_data.darken_area is None and lowest_important_level_passed:
- render_data.darken_area = old_geoms.holes
+ """
+ choose a crop area for each level. non-intermediate levels (not on_top_of) below the one that we are
+ currently rendering will be cropped to only render content that is visible through holes indoors in the
+ levels above them.
+ """
+ # area to crop each level to, by id
+ level_crop_to: dict[int, Cropper] = {}
+ # current remaining area that we're cropping to – None means no cropping
+ crop_to = None
+ primary_level_count = 0
+ main_level_passed = 0
+ lowest_important_level = None
+ last_lower_bound = None
+ for level in reversed(relevant_levels): # reversed means we are going down
+ geoms = single_level_geoms[level.pk]
- if crop_to.geometry is not None:
- map_history.composite(MapHistory.open_level(level.pk, 'base'), crop_to.geometry)
- elif render_level.pk != level.pk:
- map_history.composite(MapHistory.open_level(level.pk, 'base'), None)
+ if geoms.holes is not None:
+ primary_level_count += 1
- new_geoms = LevelGeometries()
- new_geoms.buildings = crop_to.intersection(old_geoms.buildings)
- if old_geoms.on_top_of_id is None:
- new_geoms.holes = crop_to.intersection(old_geoms.holes)
- new_geoms.doors = crop_to.intersection(old_geoms.doors)
- new_geoms.walls = crop_to.intersection(old_geoms.walls)
- new_geoms.all_walls = crop_to.intersection(old_geoms.all_walls)
- new_geoms.short_walls = tuple((altitude, geom) for altitude, geom in tuple(
- (altitude, crop_to.intersection(geom))
- for altitude, geom in old_geoms.short_walls
- ) if not geom.is_empty)
+ # get lowest intermediate level directly below main level
+ if not main_level_passed:
+ if geoms.pk == render_level.pk:
+ main_level_passed = 1
+ else:
+ if not level.on_top_of_id:
+ main_level_passed += 1
+ if main_level_passed < 2:
+ lowest_important_level = level
- for altitudearea in old_geoms.altitudeareas:
- new_geometry = crop_to.intersection(unwrap_geom(altitudearea.geometry))
- if new_geometry.is_empty:
+ # make upper bounds
+ if geoms.on_top_of_id is None:
+ if last_lower_bound is None:
+ geoms.upper_bound = geoms.max_altitude+geoms.max_height
+ else:
+ geoms.upper_bound = last_lower_bound
+ last_lower_bound = geoms.lower_bound
+
+ # set crop area if we area on the second primary layer from top or below
+ level_crop_to[level.pk] = Cropper(crop_to if primary_level_count > 1 else None)
+
+ if geoms.holes is not None: # there area holes on this area
+ if crop_to is None:
+ crop_to = geoms.holes
+ else:
+ crop_to = crop_to.intersection(geoms.holes)
+
+ if crop_to.is_empty:
+ break
+
+ render_data = LevelRenderData(
+ base_altitude=render_level.base_altitude,
+ lowest_important_level=lowest_important_level.pk,
+ )
+ access_restriction_affected = {}
+
+ # go through sublevels, get their level geometries and crop them
+ lowest_important_level_passed = False
+ for level in relevant_levels:
+ try:
+ crop_to = level_crop_to[level.pk]
+ except KeyError:
continue
- new_geometry_prep = prepared.prep(new_geometry)
- new_altitudearea = AltitudeAreaGeometries()
- new_altitudearea.geometry = new_geometry
- new_altitudearea.altitude = altitudearea.altitude
- new_altitudearea.altitude2 = altitudearea.altitude2
- new_altitudearea.point1 = altitudearea.point1
- new_altitudearea.point2 = altitudearea.point2
+ old_geoms = single_level_geoms[level.pk]
- new_colors = {}
- for color, areas in altitudearea.colors.items():
- new_areas = {}
- for access_restriction, area in areas.items():
- if not new_geometry_prep.intersects(area):
- continue
- new_area = new_geometry.intersection(area)
- if not new_area.is_empty:
- new_areas[access_restriction] = new_area
- if new_areas:
- new_colors[color] = new_areas
- new_altitudearea.colors = new_colors
+ if render_data.lowest_important_level == level.pk:
+ lowest_important_level_passed = True
- new_altitudearea_obstacles = {}
- for height, height_obstacles in altitudearea.obstacles.items():
- new_height_obstacles = {}
- for color, color_obstacles in height_obstacles.items():
- new_color_obstacles = []
- for obstacle in color_obstacles:
- if new_geometry_prep.intersects(obstacle):
- new_color_obstacles.append(
- obstacle.intersection(unwrap_geom(altitudearea.geometry))
- )
- if new_color_obstacles:
- new_height_obstacles[color] = new_color_obstacles
- if new_height_obstacles:
- new_altitudearea_obstacles[height] = new_height_obstacles
- new_altitudearea.obstacles = new_altitudearea_obstacles
+ if old_geoms.holes and render_data.darken_area is None and lowest_important_level_passed:
+ render_data.darken_area = old_geoms.holes
- new_geoms.altitudeareas.append(new_altitudearea)
+ if crop_to.geometry is not None:
+ map_history.composite(MapHistory.open_level(level.pk, 'base'), crop_to.geometry)
+ elif render_level.pk != level.pk:
+ map_history.composite(MapHistory.open_level(level.pk, 'base'), None)
- if new_geoms.walls.is_empty and not new_geoms.altitudeareas:
- continue
+ new_geoms = LevelGeometries()
+ new_geoms.buildings = crop_to.intersection(old_geoms.buildings)
+ if old_geoms.on_top_of_id is None:
+ new_geoms.holes = crop_to.intersection(old_geoms.holes)
+ new_geoms.doors = crop_to.intersection(old_geoms.doors)
+ new_geoms.walls = crop_to.intersection(old_geoms.walls)
+ new_geoms.all_walls = crop_to.intersection(old_geoms.all_walls)
+ new_geoms.short_walls = tuple((altitude, geom) for altitude, geom in tuple(
+ (altitude, crop_to.intersection(geom))
+ for altitude, geom in old_geoms.short_walls
+ ) if not geom.is_empty)
- new_geoms.ramps = tuple(
- ramp for ramp in (crop_to.intersection(unwrap_geom(ramp)) for ramp in old_geoms.ramps)
- if not ramp.is_empty
- )
+ for altitudearea in old_geoms.altitudeareas:
+ new_geometry = crop_to.intersection(unwrap_geom(altitudearea.geometry))
+ if new_geometry.is_empty:
+ continue
+ new_geometry_prep = prepared.prep(new_geometry)
- new_geoms.heightareas = tuple(
- (area, height) for area, height in ((crop_to.intersection(unwrap_geom(area)), height)
- for area, height in old_geoms.heightareas)
- if not area.is_empty
- )
+ new_altitudearea = AltitudeAreaGeometries()
+ new_altitudearea.geometry = new_geometry
+ new_altitudearea.altitude = altitudearea.altitude
+ new_altitudearea.altitude2 = altitudearea.altitude2
+ new_altitudearea.point1 = altitudearea.point1
+ new_altitudearea.point2 = altitudearea.point2
- new_geoms.affected_area = unary_union((
- *(altitudearea.geometry for altitudearea in new_geoms.altitudeareas),
- crop_to.intersection(new_geoms.walls.buffer(1)),
- *((new_geoms.holes.buffer(1),) if new_geoms.holes else ()),
- ))
+ new_colors = {}
+ for color, areas in altitudearea.colors.items():
+ new_areas = {}
+ for access_restriction, area in areas.items():
+ if not new_geometry_prep.intersects(area):
+ continue
+ new_area = new_geometry.intersection(area)
+ if not new_area.is_empty:
+ new_areas[access_restriction] = new_area
+ if new_areas:
+ new_colors[color] = new_areas
+ new_altitudearea.colors = new_colors
- for access_restriction, area in old_geoms.access_restriction_affected.items():
- new_area = crop_to.intersection(area)
- if not new_area.is_empty:
- access_restriction_affected.setdefault(access_restriction, []).append(new_area)
+ new_altitudearea_obstacles = {}
+ for height, height_obstacles in altitudearea.obstacles.items():
+ new_height_obstacles = {}
+ for color, color_obstacles in height_obstacles.items():
+ new_color_obstacles = []
+ for obstacle in color_obstacles:
+ if new_geometry_prep.intersects(obstacle):
+ new_color_obstacles.append(
+ obstacle.intersection(unwrap_geom(altitudearea.geometry))
+ )
+ if new_color_obstacles:
+ new_height_obstacles[color] = new_color_obstacles
+ if new_height_obstacles:
+ new_altitudearea_obstacles[height] = new_height_obstacles
+ new_altitudearea.obstacles = new_altitudearea_obstacles
- new_geoms.restricted_spaces_indoors = {}
- for access_restriction, area in old_geoms.restricted_spaces_indoors.items():
- new_area = crop_to.intersection(area)
- if not new_area.is_empty:
- new_geoms.restricted_spaces_indoors[access_restriction] = new_area
+ new_geoms.altitudeareas.append(new_altitudearea)
- new_geoms.restricted_spaces_outdoors = {}
- for access_restriction, area in old_geoms.restricted_spaces_outdoors.items():
- new_area = crop_to.intersection(area)
- if not new_area.is_empty:
- new_geoms.restricted_spaces_outdoors[access_restriction] = new_area
+ if new_geoms.walls.is_empty and not new_geoms.altitudeareas:
+ continue
- new_geoms.pk = old_geoms.pk
- new_geoms.on_top_of_id = old_geoms.on_top_of_id
- new_geoms.short_label = old_geoms.short_label
- new_geoms.base_altitude = old_geoms.base_altitude
- new_geoms.default_height = old_geoms.default_height
- new_geoms.door_height = old_geoms.door_height
- new_geoms.min_altitude = (min(area.altitude for area in new_geoms.altitudeareas)
- if new_geoms.altitudeareas else new_geoms.base_altitude)
- new_geoms.max_altitude = (max(area.altitude for area in new_geoms.altitudeareas)
- if new_geoms.altitudeareas else new_geoms.base_altitude)
- new_geoms.max_height = (min(height for area, height in new_geoms.heightareas)
- if new_geoms.heightareas else new_geoms.default_height)
- new_geoms.lower_bound = old_geoms.lower_bound
- new_geoms.upper_bound = old_geoms.upper_bound
+ new_geoms.ramps = tuple(
+ ramp for ramp in (crop_to.intersection(unwrap_geom(ramp)) for ramp in old_geoms.ramps)
+ if not ramp.is_empty
+ )
- new_geoms.build_mesh(interpolators.get(render_level.pk) if level.pk == render_level.pk else None)
+ new_geoms.heightareas = tuple(
+ (area, height) for area, height in ((crop_to.intersection(unwrap_geom(area)), height)
+ for area, height in old_geoms.heightareas)
+ if not area.is_empty
+ )
- render_data.levels.append(new_geoms)
+ new_geoms.affected_area = unary_union((
+ *(altitudearea.geometry for altitudearea in new_geoms.altitudeareas),
+ crop_to.intersection(new_geoms.walls.buffer(1)),
+ *((new_geoms.holes.buffer(1),) if new_geoms.holes else ()),
+ ))
- access_restriction_affected = {
- access_restriction: unary_union(areas)
- for access_restriction, areas in access_restriction_affected.items()
- }
+ for access_restriction, area in old_geoms.access_restriction_affected.items():
+ new_area = crop_to.intersection(area)
+ if not new_area.is_empty:
+ access_restriction_affected.setdefault(access_restriction, []).append(new_area)
- access_restriction_affected = AccessRestrictionAffected.build(access_restriction_affected)
- access_restriction_affected.save_level(render_level.pk, 'composite')
+ new_geoms.restricted_spaces_indoors = {}
+ for access_restriction, area in old_geoms.restricted_spaces_indoors.items():
+ new_area = crop_to.intersection(area)
+ if not new_area.is_empty:
+ new_geoms.restricted_spaces_indoors[access_restriction] = new_area
- map_history.save_level(render_level.pk, 'composite')
+ new_geoms.restricted_spaces_outdoors = {}
+ for access_restriction, area in old_geoms.restricted_spaces_outdoors.items():
+ new_area = crop_to.intersection(area)
+ if not new_area.is_empty:
+ new_geoms.restricted_spaces_outdoors[access_restriction] = new_area
- package.add_level(render_level.pk, map_history, access_restriction_affected)
+ new_geoms.pk = old_geoms.pk
+ new_geoms.on_top_of_id = old_geoms.on_top_of_id
+ new_geoms.short_label = old_geoms.short_label
+ new_geoms.base_altitude = old_geoms.base_altitude
+ new_geoms.default_height = old_geoms.default_height
+ new_geoms.door_height = old_geoms.door_height
+ new_geoms.min_altitude = (min(area.altitude for area in new_geoms.altitudeareas)
+ if new_geoms.altitudeareas else new_geoms.base_altitude)
+ new_geoms.max_altitude = (max(area.altitude for area in new_geoms.altitudeareas)
+ if new_geoms.altitudeareas else new_geoms.base_altitude)
+ new_geoms.max_height = (min(height for area, height in new_geoms.heightareas)
+ if new_geoms.heightareas else new_geoms.default_height)
+ new_geoms.lower_bound = old_geoms.lower_bound
+ new_geoms.upper_bound = old_geoms.upper_bound
- render_data.save(render_level.pk)
+ new_geoms.build_mesh(interpolators.get(render_level.pk) if level.pk == render_level.pk else None)
+
+ render_data.levels.append(new_geoms)
+
+ access_restriction_affected = {
+ access_restriction: unary_union(areas)
+ for access_restriction, areas in access_restriction_affected.items()
+ }
+
+ access_restriction_affected = AccessRestrictionAffected.build(access_restriction_affected)
+ access_restriction_affected.save_level(render_level.pk, 'composite')
+
+ map_history.save_level(render_level.pk, 'composite')
+
+ package.add_level(render_level.pk, theme, map_history, access_restriction_affected)
+
+ render_data.save(render_level.pk, theme)
package.save_all()
cached = LocalContext()
@staticmethod
- def _level_filename(pk):
- return settings.CACHE_ROOT / ('render_data_level_%d.pickle' % pk)
+ def _level_filename(level_pk, theme_pk):
+ if theme_pk is None:
+ name = 'render_data_level_%d.pickle' % level_pk
+ else:
+ name = 'render_data_level_%d_theme_%d.pickle' % (level_pk, theme_pk)
+ return settings.CACHE_ROOT / name
@classmethod
- def get(cls, level):
+ def get(cls, level, theme):
# get the current render data from local variable if no new processed mapupdate exists.
# this is much faster than any other possible cache
cache_key = MapUpdate.current_processed_cache_key()
- level_pk = str(level.pk if isinstance(level, Level) else level)
+ level_pk = level.pk if isinstance(level, Level) else level
+ theme_pk = theme.pk if isinstance(theme, Theme) else theme
+ key = f'{level_pk}_{theme_pk}'
if getattr(cls.cached, 'key', None) != cache_key:
cls.cached.key = cache_key
cls.cached.data = {}
else:
- result = cls.cached.data.get(level_pk, None)
+ result = cls.cached.data.get(key, None)
if result is not None:
return result
- pk = level.pk if isinstance(level, Level) else level
- result = pickle.load(open(cls._level_filename(pk), 'rb'))
+ result = pickle.load(open(cls._level_filename(level_pk, theme_pk), 'rb'))
- cls.cached.data[level_pk] = result
+ cls.cached.data[key] = result
return result
- def save(self, pk):
- return pickle.dump(self, open(self._level_filename(pk), 'wb'))
+ def save(self, level_pk, theme_pk):
+ return pickle.dump(self, open(self._level_filename(level_pk, theme_pk), 'wb'))
diff --git a/src/c3nav/mapdata/render/renderer.py b/src/c3nav/mapdata/render/renderer.py
index 71dc5f43..824af50f 100644
--- a/src/c3nav/mapdata/render/renderer.py
+++ b/src/c3nav/mapdata/render/renderer.py
@@ -6,16 +6,10 @@ from c3nav.mapdata.models import Level
from c3nav.mapdata.render.engines.base import FillAttribs, StrokeAttribs
from c3nav.mapdata.render.geometry import hybrid_union
from c3nav.mapdata.render.renderdata import LevelRenderData
+from c3nav.mapdata.render.theme import ColorManager
from c3nav.mapdata.render.utils import get_full_levels, get_min_altitude
from c3nav.mapdata.utils.color import color_to_rgb, rgb_to_color
-RENDER_COLOR_BACKGROUND = "#DCDCDC"
-RENDER_COLOR_WALL_FILL = "#aaaaaa"
-RENDER_COLOR_WALL_BORDER = "#666666"
-RENDER_COLOR_DOOR_FILL = "#ffffff"
-RENDER_COLOR_GROUND_FILL = "#eeeeee"
-RENDER_COLOR_OBSTACLES_DEFAULT_FILL = "#b7b7b7"
-RENDER_COLOR_OBSTACLES_DEFAULT_BORDER = "#888888"
class MapRenderer:
@@ -38,16 +32,17 @@ class MapRenderer:
def bbox(self):
return box(self.minx-1, self.miny-1, self.maxx+1, self.maxy+1)
- def render(self, engine_cls, center=True):
+ def render(self, engine_cls, theme, center=True):
+ color_manager = ColorManager.for_theme(theme)
# add no access restriction to “unlocked“ access restrictions so lookup gets easier
access_permissions = self.access_permissions | {None}
bbox = prepared.prep(self.bbox)
- level_render_data = LevelRenderData.get(self.level)
+ level_render_data = LevelRenderData.get(self.level, theme)
engine = engine_cls(self.width, self.height, self.minx, self.miny, float(level_render_data.base_altitude),
- scale=self.scale, buffer=1, background=RENDER_COLOR_BACKGROUND,
+ scale=self.scale, buffer=1, background=color_manager.background,
center=center, min_width=self.min_width)
if hasattr(engine, 'custom_render'):
@@ -81,17 +76,17 @@ class MapRenderer:
).union(add_walls)
if not_full_levels:
- engine.add_geometry(geoms.walls_base, fill=FillAttribs(RENDER_COLOR_WALL_FILL), category='walls')
+ engine.add_geometry(geoms.walls_base, fill=FillAttribs(color_manager.wall_fill), category='walls')
engine.add_geometry(geoms.walls_bottom.fit(scale=geoms.min_altitude-min_altitude,
offset=min_altitude-int(0.7*1000)),
- fill=FillAttribs(RENDER_COLOR_WALL_FILL), category='walls')
+ fill=FillAttribs(color_manager.wall_fill), category='walls')
for i, altitudearea in enumerate(geoms.altitudeareas):
base = altitudearea.base.difference(crop_areas)
bottom = altitudearea.bottom.difference(crop_areas)
- engine.add_geometry(base, fill=FillAttribs(RENDER_COLOR_GROUND_FILL), category='ground', item=i)
+ engine.add_geometry(base, fill=FillAttribs(color_manager.ground_fill), category='ground', item=i)
engine.add_geometry(bottom.fit(scale=geoms.min_altitude - min_altitude,
offset=min_altitude - int(0.7 * 1000)),
- fill=FillAttribs(RENDER_COLOR_WALL_FILL), category='ground')
+ fill=FillAttribs(color_manager.wall_fill), category='ground')
# render altitude areas in default ground color and add ground colors to each one afterwards
# shadows are directly calculated and added by the engine
@@ -100,7 +95,7 @@ class MapRenderer:
if not_full_levels:
geometry = geometry.filter(bottom=False)
engine.add_geometry(geometry, altitude=altitudearea.altitude,
- fill=FillAttribs(RENDER_COLOR_GROUND_FILL), category='ground', item=i)
+ fill=FillAttribs(color_manager.ground_fill), category='ground', item=i)
j = 0
for (order, color), areas in altitudearea.colors.items():
@@ -131,8 +126,8 @@ class MapRenderer:
else:
engine.add_geometry(
obstacle_geom,
- fill=FillAttribs(RENDER_COLOR_OBSTACLES_DEFAULT_FILL),
- stroke=StrokeAttribs(RENDER_COLOR_OBSTACLES_DEFAULT_BORDER, 0.05, min_px=0.2),
+ fill=FillAttribs(color_manager.obstacles_default_fill),
+ stroke=StrokeAttribs(color_manager.obstacles_default_border, 0.05, min_px=0.2),
category='obstacles'
)
@@ -147,29 +142,29 @@ class MapRenderer:
engine.add_geometry(
walls.filter(bottom=not not_full_levels,
top=not walls_extended),
- height=geoms.default_height, fill=FillAttribs(RENDER_COLOR_WALL_FILL), category='walls'
+ height=geoms.default_height, fill=FillAttribs(color_manager.wall_fill), category='walls'
)
for short_wall in geoms.short_walls:
engine.add_geometry(short_wall.filter(bottom=not not_full_levels),
- fill=FillAttribs(RENDER_COLOR_WALL_FILL), category='walls')
+ fill=FillAttribs(color_manager.wall_fill), category='walls')
if walls_extended:
- engine.add_geometry(geoms.walls_extended, fill=FillAttribs(RENDER_COLOR_WALL_FILL), category='walls')
+ engine.add_geometry(geoms.walls_extended, fill=FillAttribs(color_manager.wall_fill), category='walls')
doors_extended = geoms.doors_extended and full_levels
if not geoms.doors.is_empty:
engine.add_geometry(geoms.doors.difference(add_walls).filter(top=not doors_extended),
- fill=FillAttribs(RENDER_COLOR_DOOR_FILL),
- stroke=StrokeAttribs(RENDER_COLOR_DOOR_FILL, 0.05, min_px=0.2),
+ fill=FillAttribs(color_manager.door_fill),
+ stroke=StrokeAttribs(color_manager.door_fill, 0.05, min_px=0.2),
category='doors')
if doors_extended:
- engine.add_geometry(geoms.doors_extended, fill=FillAttribs(RENDER_COLOR_WALL_FILL), category='doors')
+ engine.add_geometry(geoms.doors_extended, fill=FillAttribs(color_manager.wall_fill), category='doors')
if walls is not None:
engine.add_geometry(walls,
- stroke=StrokeAttribs(RENDER_COLOR_WALL_BORDER, 0.1, min_px=1),
+ stroke=StrokeAttribs(color_manager.wall_border, 0.1, min_px=1),
category='walls')
if geoms.on_top_of_id is None:
diff --git a/src/c3nav/mapdata/render/theme.py b/src/c3nav/mapdata/render/theme.py
new file mode 100644
index 00000000..6c28c59e
--- /dev/null
+++ b/src/c3nav/mapdata/render/theme.py
@@ -0,0 +1,91 @@
+from c3nav.mapdata.models import LocationGroup
+from c3nav.mapdata.models.geometry.space import ObstacleGroup
+from c3nav.mapdata.models.theme import Theme
+
+RENDER_COLOR_BACKGROUND = "#DCDCDC"
+RENDER_COLOR_WALL_FILL = "#aaaaaa"
+RENDER_COLOR_WALL_BORDER = "#666666"
+RENDER_COLOR_DOOR_FILL = "#ffffff"
+RENDER_COLOR_GROUND_FILL = "#eeeeee"
+RENDER_COLOR_OBSTACLES_DEFAULT_FILL = "#b7b7b7"
+RENDER_COLOR_OBSTACLES_DEFAULT_BORDER = "#888888"
+
+
+class ThemeColorManager:
+ # TODO: border colors are not implemented yet?
+ def __init__(self, theme: Theme = None):
+ if theme is None:
+ self.background = RENDER_COLOR_BACKGROUND
+ self.wall_fill = RENDER_COLOR_WALL_FILL
+ self.wall_border = RENDER_COLOR_WALL_BORDER
+ self.door_fill = RENDER_COLOR_DOOR_FILL
+ self.ground_fill = RENDER_COLOR_GROUND_FILL
+ self.obstacles_default_fill = RENDER_COLOR_OBSTACLES_DEFAULT_FILL
+ self.obstacles_default_border = RENDER_COLOR_OBSTACLES_DEFAULT_BORDER
+ self.location_group_border_colors = {}
+ self.location_group_fill_colors = {
+ l.pk: l.color
+ for l in LocationGroup.objects.filter(color__isnull=False).all()
+ }
+ self.obstacle_group_border_colors = {}
+ self.obstacle_group_fill_colors = {
+ o.pk: o.color
+ for o in ObstacleGroup.objects.filter(color__isnull=False).all()
+ }
+ else:
+ self.background = theme.color_background
+ self.wall_fill = theme.color_wall_fill
+ self.wall_border = theme.color_wall_border
+ self.door_fill = theme.color_door_fill
+ self.ground_fill = theme.color_ground_fill
+ self.obstacles_default_fill = theme.color_obstacles_default_fill
+ self.obstacles_default_border = theme.color_obstacles_default_border
+ self.location_group_border_colors = {
+ l.location_group_id: l.border_color
+ for l in theme.location_groups.all()
+ }
+ self.location_group_fill_colors = {
+ l.location_group_id: l.fill_color
+ for l in theme.location_groups.all()
+ }
+ self.obstacle_group_border_colors = {
+ o.obstacle_group_id: o.border_color
+ for o in theme.obstacle_groups.all()
+ }
+ self.obstacle_group_fill_colors = {
+ o.obstacle_group_id: o.fill_color
+ for o in theme.obstacle_groups.all()
+ }
+
+ def locationgroup_border_color(self, location_group: LocationGroup):
+ return self.location_group_border_colors.get(location_group.pk, None)
+
+ def locationgroup_fill_color(self, location_group: LocationGroup):
+ return self.location_group_fill_colors.get(location_group.pk, None)
+
+ def obstaclegroup_border_color(self, obstacle_group: ObstacleGroup):
+ return self.obstacle_group_border_colors.get(obstacle_group.pk, self.obstacles_default_border)
+
+ def obstaclegroup_fill_color(self, obstacle_group: ObstacleGroup):
+ return self.obstacle_group_fill_colors.get(obstacle_group.pk, self.obstacles_default_fill)
+
+
+class ColorManager:
+ themes = {}
+ default_theme = None
+
+ @classmethod
+ def for_theme(cls, theme):
+ if theme is None:
+ if cls.default_theme is None:
+ cls.default_theme = ThemeColorManager()
+ return cls.default_theme
+ if not isinstance(theme, Theme):
+ theme = Theme.objects.get(pk=theme)
+ if theme.pk not in cls.themes:
+ cls.themes[theme.pk] = ThemeColorManager(theme)
+ return cls.themes[theme.pk]
+
+ @classmethod
+ def refresh(cls):
+ cls.themes.clear()
diff --git a/src/c3nav/mapdata/urls.py b/src/c3nav/mapdata/urls.py
index 91df07d2..ecd8ecea 100644
--- a/src/c3nav/mapdata/urls.py
+++ b/src/c3nav/mapdata/urls.py
@@ -13,10 +13,10 @@ register_converter(HistoryFileExtConverter, 'h_fileext')
register_converter(ArchiveFileExtConverter, 'archive_fileext')
urlpatterns = [
- path('///.png', tile, name='mapdata.tile'),
+ path('////.png', tile, name='mapdata.tile'),
path('preview/l/.png', preview_location, name='mapdata.preview.location'),
path('preview/r//.png', preview_route, name='mapdata.preview.route'),
- path('////.png', tile, name='mapdata.tile'),
+ path('/////.png', tile, name='mapdata.tile'),
path('history//.', map_history, name='mapdata.map_history'),
path('cache/package.', get_cache_package, name='mapdata.cache_package'),
]
diff --git a/src/c3nav/mapdata/utils/cache/package.py b/src/c3nav/mapdata/utils/cache/package.py
index 4bdcd96a..ce36c1da 100644
--- a/src/c3nav/mapdata/utils/cache/package.py
+++ b/src/c3nav/mapdata/utils/cache/package.py
@@ -23,9 +23,12 @@ class CachePackage:
def __init__(self, bounds, levels=None):
self.bounds = bounds
self.levels = {} if levels is None else levels
+ self.theme_ids = []
- def add_level(self, level_id: int, history: MapHistory, restrictions: AccessRestrictionAffected):
- self.levels[level_id] = CachePackageLevel(history, restrictions)
+ def add_level(self, level_id: int, theme_id, history: MapHistory, restrictions: AccessRestrictionAffected):
+ self.levels[(level_id, theme_id)] = CachePackageLevel(history, restrictions)
+ if theme_id not in self.theme_ids:
+ self.theme_ids.append(theme_id)
def save(self, filename=None, compression=None):
if filename is None:
@@ -50,9 +53,13 @@ class CachePackage:
self._add_bytesio(f, 'bounds',
BytesIO(struct.pack('