rudimentary theme support

This commit is contained in:
Gwendolyn 2024-01-06 13:26:34 +01:00
parent b44197a1c5
commit 9399e5c377
23 changed files with 925 additions and 307 deletions

View file

@ -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'),

View file

@ -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',

View file

@ -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;
}

View file

@ -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]');

View file

@ -73,6 +73,15 @@
<table></table>
</div>
<div class="theme-editor-filter">
<div class="btn-group" role="group">
<button type="button" class="btn btn-default active" data-all>{% trans 'All' %}</button>
<button type="button" class="btn btn-default" data-base-theme>{% trans 'In base theme' %}</button>
<button type="button" class="btn btn-default" data-any-theme>{% trans 'In any theme' %}</button>
</div>
</div>
<span class="theme-color-info">{% trans 'Other theme colors' %}</span>
{% include 'site/fragment_fakemobileclient.html' %}
{% compress js %}
<script type="text/javascript" src="{% static 'jquery/jquery.js' %}"></script>

View file

@ -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'))

View file

@ -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'))

View file

@ -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'),
),
]

View file

@ -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)
]

View file

@ -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',
)
]

View file

@ -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'):

View file

@ -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)

View file

@ -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):

View file

@ -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)

View file

@ -63,7 +63,7 @@ class LevelGeometries:
return '<LevelGeometries for Level %s (#%d)>' % (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))
)

View file

@ -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'))

View file

@ -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:

View file

@ -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()

View file

@ -13,10 +13,10 @@ register_converter(HistoryFileExtConverter, 'h_fileext')
register_converter(ArchiveFileExtConverter, 'archive_fileext')
urlpatterns = [
path('<int:level>/<sint:zoom>/<sint:x>/<sint:y>.png', tile, name='mapdata.tile'),
path('<int:level>/<sint:zoom>/<sint:x>/<sint:y>/<int:theme>.png', tile, name='mapdata.tile'),
path('preview/l/<loc:slug>.png', preview_location, name='mapdata.preview.location'),
path('preview/r/<loc:slug>/<loc:slug2>.png', preview_route, name='mapdata.preview.route'),
path('<int:level>/<sint:zoom>/<sint:x>/<sint:y>/<a_perms:access_permissions>.png', tile, name='mapdata.tile'),
path('<int:level>/<sint:zoom>/<sint:x>/<sint:y>/<int:theme>/<a_perms:access_permissions>.png', tile, name='mapdata.tile'),
path('history/<int:level>/<h_mode:mode>.<h_fileext:filetype>', map_history, name='mapdata.map_history'),
path('cache/package.<archive_fileext:filetype>', get_cache_package, name='mapdata.cache_package'),
]

View file

@ -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('<iiii', *(int(i*100) for i in self.bounds))))
for level_id, level_data in self.levels.items():
self._add_geometryindexed(f, 'history_%d' % level_id, level_data.history)
self._add_geometryindexed(f, 'restrictions_%d' % level_id, level_data.restrictions)
for (level_id, theme_id), level_data in self.levels.items():
if theme_id is None:
key = '%d' % level_id
else:
key = '%d_%d' % (level_id, theme_id)
self._add_geometryindexed(f, 'history_%s' % key, level_data.history)
self._add_geometryindexed(f, 'restrictions_%s' % key, level_data.restrictions)
finally:
if fileobj is not None:
fileobj.close()
@ -99,10 +106,15 @@ class CachePackage:
for filename in files:
if not filename.startswith('history_'):
continue
level_id = int(filename[8:])
levels[level_id] = CachePackageLevel(
history=MapHistory.read(f.extractfile(files['history_%d' % level_id])),
restrictions=AccessRestrictionAffected.read(f.extractfile(files['restrictions_%d' % level_id]))
key = filename[8:]
if '_' in key:
[level_id, theme_id] = [int(x) for x in key.split('_', 1)]
else:
level_id = int(key)
theme_id = None
levels[(level_id, theme_id)] = CachePackageLevel(
history=MapHistory.read(f.extractfile(files['history_%s' % key])),
restrictions=AccessRestrictionAffected.read(f.extractfile(files['restrictions_%s' % key]))
)
return cls(bounds, levels)

View file

@ -48,8 +48,8 @@ def build_access_cache_key(access_permissions: set):
return '-'.join(str(i) for i in sorted(access_permissions)) or '0'
def build_tile_etag(level_id, zoom, x, y, base_cache_key, access_cache_key, tile_secret):
def build_tile_etag(level_id, zoom, x, y, theme_id, base_cache_key, access_cache_key, tile_secret):
# we want a short etag so HTTP 304 responses are tiny
return '"' + binascii.b2a_base64(hashlib.sha256(
('%d-%d-%d-%d:%s:%s:%s' % (level_id, zoom, x, y, base_cache_key, access_cache_key, tile_secret[:26])).encode()
('%d-%d-%d-%d:%s:%s:%s:%s' % (level_id, zoom, x, y, str(theme_id), base_cache_key, access_cache_key, tile_secret[:26])).encode()
).digest()[:15], newline=False).decode() + '"'

View file

@ -187,13 +187,15 @@ def preview_location(request, slug):
minx, miny, maxx, maxy, img_scale = bounds_for_preview(unary_union(geometries), cache_package)
level_data = cache_package.levels.get(level)
theme = None # previews are unthemed
level_data = cache_package.levels.get((level, theme))
if level_data is None:
raise Http404
def render_preview():
renderer = MapRenderer(level, minx, miny, maxx, maxy, scale=img_scale, access_permissions=set())
image = renderer.render(ImageRenderEngine)
image = renderer.render(ImageRenderEngine, theme)
if highlight:
for geometry in geometries:
image.add_geometry(geometry,
@ -291,13 +293,15 @@ def preview_route(request, slug, slug2):
minx, miny, maxx, maxy, img_scale = bounds_for_preview(combined_geometry, cache_package)
level_data = cache_package.levels.get(origin_level)
theme = None # previews are unthemed
level_data = cache_package.levels.get((origin_level, theme))
if level_data is None:
raise Http404
def render_preview():
renderer = MapRenderer(origin_level, minx, miny, maxx, maxy, scale=img_scale, access_permissions=set())
image = renderer.render(ImageRenderEngine)
image = renderer.render(ImageRenderEngine, theme)
if origin_geometry is not None:
image.add_geometry(origin_geometry,
@ -321,7 +325,7 @@ def preview_route(request, slug, slug2):
@no_language()
def tile(request, level, zoom, x, y, access_permissions: Optional[set] = None):
def tile(request, level, zoom, x, y, theme, access_permissions: Optional[set] = None):
if access_permissions is not None:
enforce_tile_secret_auth(request)
elif settings.TILE_CACHE_SERVER:
@ -341,9 +345,12 @@ def tile(request, level, zoom, x, y, access_permissions: Optional[set] = None):
if not cache_package.bounds_valid(minx, miny, maxx, maxy):
raise Http404
theme = None if theme is 0 else int(theme)
theme_key = str(theme)
# get level
level = int(level)
level_data = cache_package.levels.get(level)
level_data = cache_package.levels.get((level, theme))
if level_data is None:
raise Http404
@ -365,7 +372,7 @@ def tile(request, level, zoom, x, y, access_permissions: Optional[set] = None):
access_cache_key = build_access_cache_key(access_permissions)
# check browser cache
tile_etag = build_tile_etag(level, zoom, x, y, base_cache_key, access_cache_key, settings.SECRET_TILE_KEY)
tile_etag = build_tile_etag(level, zoom, x, y, theme_key, base_cache_key, access_cache_key, settings.SECRET_TILE_KEY)
if_none_match = request.META.get('HTTP_IF_NONE_MATCH')
if if_none_match == tile_etag:
return HttpResponseNotModified()
@ -375,9 +382,9 @@ def tile(request, level, zoom, x, y, access_permissions: Optional[set] = None):
# get tile cache last update
if settings.CACHE_TILES:
tile_directory = settings.TILES_ROOT / str(level) / str(zoom) / str(x) / str(y)
tile_directory = settings.TILES_ROOT / str(level) / str(zoom) / str(x) / str(y) / access_cache_key
last_update_file = tile_directory / 'last_update'
tile_file = tile_directory / (access_cache_key + '.png')
tile_file = tile_directory / f'{theme_key}.png'
# get tile cache last update
tile_cache_update_cache_key = 'mapdata:tile-cache-update:%d-%d-%d-%d' % (level, zoom, x, y)
@ -403,7 +410,7 @@ def tile(request, level, zoom, x, y, access_permissions: Optional[set] = None):
if data is None:
renderer = MapRenderer(level, minx, miny, maxx, maxy, scale=2 ** zoom, access_permissions=access_permissions)
image = renderer.render(ImageRenderEngine)
image = renderer.render(ImageRenderEngine, theme=theme)
data = image.render()
if settings.CACHE_TILES:

View file

@ -1439,6 +1439,8 @@ c3nav = {
c3nav._gridControl = new SquareGridControl().addTo(c3nav.map);
}
new ThemeControl().addTo(c3nav.map);
// setup user location control
c3nav._userLocationControl = new UserLocationControl().addTo(c3nav.map);
@ -1451,6 +1453,17 @@ c3nav = {
c3nav.schedule_fetch_updates();
},
theme: null,
show_theme_select: function() {
// TODO: actual theme selection
if (c3nav.theme === null) {
c3nav.theme = 1;
} else {
c3nav.theme = null;
}
c3nav._levelControl.setTheme(c3nav.theme);
// openInModal('/theme')
},
_click_anywhere_popup: null,
_click_anywhere: function(e) {
if (e.originalEvent.target.id !== 'map') return;
@ -2019,13 +2032,31 @@ LevelControl = L.Control.extend({
return this._container;
},
createTileLayer: function(id) {
return L.tileLayer((c3nav.tile_server || '/map/') + String(id) + '/{z}/{x}/{y}.png', {
createTileLayer: function(id, theme = null) {
let urlPattern = (c3nav.tile_server || '/map/') + `${id}/{z}/{x}/{y}`;
if (theme) {
urlPattern += `/${theme}`;
}
urlPattern += '.png';
return L.tileLayer(urlPattern, {
minZoom: -2,
maxZoom: 5,
bounds: L.GeoJSON.coordsToLatLngs(c3nav.bounds)
});
},
setTheme: function(theme) {
if (this.currentLevel !== null) {
this._tileLayers[this.currentLevel].remove();
}
for (const id in this._tileLayers) {
this._tileLayers[id] = this.createTileLayer(id, theme);
}
if (this.currentLevel !== null) {
this._tileLayers[this.currentLevel].addTo(c3nav.map);
}
},
addLevel: function (id, title) {
this._tileLayers[id] = this.createTileLayer(id);
var overlay = L.layerGroup();
@ -2049,7 +2080,7 @@ LevelControl = L.Control.extend({
if (id === this.currentLevel) return true;
if (id !== null && this._tileLayers[id] === undefined) return false;
if (this.currentLevel) {
if (this.currentLevel !== null) {
this._tileLayers[this.currentLevel].remove();
this._overlayLayers[this.currentLevel].remove();
L.DomUtil.removeClass(this._levelButtons[this.currentLevel], 'current');
@ -2206,6 +2237,21 @@ SquareGridControl = L.Control.extend({
}
});
ThemeControl = L.Control.extend({
options: {
position: 'bottomright',
addClasses: '',
},
onAdd: function() {
this._container = L.DomUtil.create('div', 'leaflet-control-theme leaflet-bar ' + this.options.addClasses);
this._button = L.DomUtil.create('a', 'material-symbols', this._container);
$(this._button).click(c3nav.show_theme_select).dblclick(function(e) { e.stopPropagation(); });
this._button.innerText = c3nav._map_material_icon('contrast');
this._button.href = '#';
return this._container;
},
})
L.SquareGridLayer = L.Layer.extend({
initialize: function (config) {