rudimentary theme support
This commit is contained in:
parent
b44197a1c5
commit
9399e5c377
23 changed files with 925 additions and 307 deletions
|
@ -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'),
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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]');
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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'))
|
||||
|
|
|
@ -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'))
|
||||
|
||||
|
|
79
src/c3nav/mapdata/migrations/0099_theming.py
Normal file
79
src/c3nav/mapdata/migrations/0099_theming.py
Normal 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'),
|
||||
),
|
||||
]
|
|
@ -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)
|
||||
]
|
17
src/c3nav/mapdata/migrations/0101_remove_obstacle_color.py
Normal file
17
src/c3nav/mapdata/migrations/0101_remove_obstacle_color.py
Normal 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',
|
||||
)
|
||||
]
|
|
@ -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'):
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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):
|
||||
|
|
58
src/c3nav/mapdata/models/theme.py
Normal file
58
src/c3nav/mapdata/models/theme.py
Normal 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)
|
|
@ -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))
|
||||
)
|
||||
|
||||
|
|
|
@ -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'))
|
||||
|
|
|
@ -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:
|
||||
|
|
91
src/c3nav/mapdata/render/theme.py
Normal file
91
src/c3nav/mapdata/render/theme.py
Normal 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()
|
|
@ -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'),
|
||||
]
|
||||
|
|
30
src/c3nav/mapdata/utils/cache/package.py
vendored
30
src/c3nav/mapdata/utils/cache/package.py
vendored
|
@ -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)
|
||||
|
|
|
@ -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() + '"'
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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) {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue