Browse Source

maps now render, better color support and parameterizable palettes

master
phryk 2 weeks ago
parent
commit
ed16fd0294
20 changed files with 906 additions and 405 deletions
  1. +4
    -4
      example.py
  2. +0
    -4
      poobrains/analysis/__init__.py
  3. +27
    -11
      poobrains/analysis/data.py
  4. +57
    -46
      poobrains/analysis/editor.py
  5. +0
    -32
      poobrains/analysis/util.py
  6. +510
    -29
      poobrains/analysis/visualization.py
  7. +0
    -2
      poobrains/defaults.py
  8. +33
    -10
      poobrains/storage/fields.py
  9. +80
    -0
      poobrains/svg/__init__.py
  10. +46
    -87
      poobrains/svg/color.py
  11. +94
    -0
      poobrains/svg/palettes.py
  12. +3
    -134
      poobrains/themes/default/svg.scss
  13. +1
    -1
      poobrains/themes/default/svg/barchart-inline.jinja
  14. +0
    -1
      poobrains/themes/default/svg/boxplot-raw.jinja
  15. +1
    -1
      poobrains/themes/default/svg/lineplot-inline.jinja
  16. +37
    -0
      poobrains/themes/default/svg/map-inline.jinja
  17. +2
    -38
      poobrains/themes/default/svg/map-raw.jinja
  18. +4
    -2
      poobrains/themes/default/svg/scatterplot-inline.jinja
  19. +1
    -1
      poobrains/themes/default/svg/scatterplot-raw.jinja
  20. +6
    -2
      poobrains/themes/default/svg/svg.jinja

+ 4
- 4
example.py View File

@@ -148,7 +148,7 @@ class NEO_Approaches(poobrains.analysis.EphemeralDataset):
ds.description: "**%s** belongs to orbit class **%s**; %s" % (data['name'], data['orbital_data']['orbit_class']['orbit_class_type'], data['orbital_data']['orbit_class']['orbit_class_description'])

ds.plot_data = {
'kind': 'line',
'kind': 'LinePlot',
'layers': {
'approaches': {
'x': 'time',
@@ -205,7 +205,7 @@ class Stock_Weekly(poobrains.analysis.EphemeralDataset):
ds.description = f"Data for stock symbol **{symbol}** from *{first_date}* to *{last_date}*."

ds.plot_data = {
'kind': 'line',
'kind': 'LinePlot',
'layers': {
symbol: {
'x': 'time',
@@ -261,7 +261,7 @@ class ConstrainedRandom(poobrains.analysis.EphemeralDataset):
ds.description = f"ConstrainedRandom of magnitude {magnitude} and length {length}."

ds.plot_data = {
'kind': 'line',
'kind': 'LinePlot',
'layers': {
'plot': {
'x': 'x',
@@ -315,7 +315,7 @@ class Sine(poobrains.analysis.EphemeralDataset):
ds.description = f"A full sine wave out of {length} points."
ds.plot_data = {
'kind': 'line',
'kind': 'LinePlot',
'layers': {
'plot': {
'x': 'x',


+ 0
- 4
poobrains/analysis/__init__.py View File

@@ -4,11 +4,7 @@ from poobrains import app

from . import util
from . import data
#from . import geo
from . import sources

EphemeralDataset = data.EphemeralDataset
Dataset = data.Dataset

#EphemeralGeoData = geo.EphemeralGeoData
#GeoData = geo.GeoData

+ 27
- 11
poobrains/analysis/data.py View File

@@ -95,7 +95,7 @@ class EphemeralDataset(poobrains.auth.Protected):
self._name = name
self.title = title or type(self).__name__
self.description = description or ''
self.plot_data = None #{'kind': 'ScatterPlot', 'layers': {}}
self.plot_data = {'kind': 'ScatterPlot', 'layers': {}}
self.data = data if not data is None else {}

def __len__(self):
@@ -136,6 +136,26 @@ class EphemeralDataset(poobrains.auth.Protected):
def keys(self):
return self.data.keys()

def columns_of_dtype(self, dtypes):

if not isinstance(dtypes, (list, tuple)):
dtypes = [dtypes]

columns = {}

unchecked = dict(self.items())
for dtype in dtypes:
new = set()
for column_name, column in unchecked.items():
if column['dtype'] == dtype:
new.add(column_name)

for name in new:
columns[name] = unchecked[name]
del(unchecked[name])

return columns

@property
def name(self):
return self._name or type(self).__name__.lower()
@@ -362,7 +382,7 @@ class EphemeralDataset(poobrains.auth.Protected):
if kind is None:
kind = self.plot_data['kind']

plot_kinds = visualization.Plot.class_children()
plot_kinds = visualization.Plot.class_children_keyed()

if not kind in plot_kinds:
raise ValueError('Unknown plot kind: %s' % kind)
@@ -412,21 +432,17 @@ class EphemeralDataset(poobrains.auth.Protected):
return ds


class DataFieldAccessor(poobrains.storage.fields.BSONFieldAccessor):
class DataField(poobrains.storage.fields.BSONField):

def serialize_preprocess(self, value):
form_widget = None

def db_preprocess(self, value):
return util.data_to_dict(value)

def deserialize_postprocess(self, value):
def python_postprocess(self, value):
return util.dict_to_data(value)


class DataField(poobrains.storage.fields.BSONField):
accessor_class = DataFieldAccessor
#form_widget = poobrains.form.fields.File
form_widget = None


class Dataset(EphemeralDataset, poobrains.commenting.Commentable):

class Meta:


+ 57
- 46
poobrains/analysis/editor.py View File

@@ -63,86 +63,72 @@ class EditorPlotActionControl(poobrains.form.Fieldset):
self.editor = editor

action = editor.session['action']
if isinstance(editor.dataset.plot_data, dict):
kind_default = editor.dataset.plot_data.get('kind', 'ScatterPlot')
else:
kind_default = 'ScatterPlot'
plot_kind = self.editor.dataset.plot_data.get('kind', 'ScatterPlot')
plot_cls = poobrains.analysis.visualization.Plot.class_children_keyed()[plot_kind]

self.kind = poobrains.form.fields.Select(choices=plot_kind_choices(), default=kind_default)
self.kind_apply = poobrains.form.Button('submit', label='Apply plot type')
self['action'] = poobrains.form.fields.Select(label='Action', choices=(('kind', 'Change plot kind'), ('options', 'Edit plot options'), ('layer_add', 'Add layer'), ('layer_edit', 'Edit layer'), ('layer_delete layer(s)', 'Delete')))
self.cancel = poobrains.form.Button('submit', label='Cancel')
if not action is None:
self['action'].value = action.split('.')[1]
self['action'].readonly = True

if action == 'plot.kind':
self['kind'] = poobrains.form.fields.Select(choices=plot_kind_choices(), default=plot_kind)
self.apply_kind = poobrains.form.Button('submit', label='Apply')

elif action == 'plot.options':
self['plot_options'] = plot_cls.editor_fieldset(self.editor)
self.apply_options = poobrains.form.Button('submit', label='Apply')

if action == 'plot.add':
self['layer_name'] = poobrains.form.fields.Text(label="Name", help_text='machine readable ([a-z], [0-9], -)', validators=[poobrains.form.validators.valid_name], required=True)
self['layer_x'] = poobrains.form.fields.Select(label="X", help_text="Which column to use as X-axis.", choices=self.editor.column_choices)
self['layer_y'] = poobrains.form.fields.Select(label="Y", help_text="Which column to use as Y-axis.", choices=self.editor.column_choices)
elif action == 'plot.layer_add':
self['layer_add'] = plot_cls.editor_layer_fieldset(self.editor)
self.add = poobrains.form.Button('submit', label='Add')

elif action == 'plot.edit':
elif action == 'plot.layer_edit':
self['layer_name'] = poobrains.form.fields.Select(label="Layer", choices=self.editor.layer_choices)
self.edit_choose = poobrains.form.Button('submit', label='Choose')

elif action == 'plot.edit.layer':
layer_name = self.editor.session['action_data']['layer_name']
layer = self.editor.dataset.plot_data['layers'][layer_name]
self['layer_name'] = poobrains.form.fields.Select(label="Layer", choices=self.editor.layer_choices, default=layer_name)
self['layer_x'] = poobrains.form.fields.Select(label="X", help_text="Which column to use as X-axis.", choices=self.editor.column_choices, default=layer['x'])
self['layer_y'] = poobrains.form.fields.Select(label="Y", help_text="Which column to use as Y-axis.", choices=self.editor.column_choices, default=layer['y'])
elif action == 'plot.layer_edit.layer':
self['layer_edit'] = plot_cls.editor_layer_fieldset(self.editor)
self.edit_save = poobrains.form.Button('submit', label='Save')

elif action == 'plot.delete':
elif action == 'plot.layer_delete':
self['layers'] = poobrains.form.fields.Select(label="Layer(s)", choices=self.editor.layer_choices, multi=True)
self.delete = poobrains.form.Button('submit', label='Delete')

else:
self.action = poobrains.form.fields.Select(label='Action', choices=(('add', 'Add'), ('edit', 'Edit'), ('delete', 'Delete')))
self.action_choose = poobrains.form.Button('submit', label='Choose')
del(self.controls['cancel'])

def process(self, submit):

app.debugger.set_trace()
if submit == 'cancel':
self.editor.session['action'] = None
self.editor.session['action_data'] = None
flash("Cancelled action.")

elif submit == 'kind_apply':
self.editor.dataset.plot_data['kind'] = self['kind'].value
flash(f"Set plot type to '{self['kind'].value}'.")

elif submit == 'action_choose':
if submit == 'action_choose':
self.editor.session['action'] = f"plot.{self['action'].value}"

elif submit == 'add':
self.editor.dataset.plot_data['layers'][self['layer_name'].value] = {
'x': self['layer_x'].value,
'y': self['layer_y'].value
}

flash(f"Successfully added layer '{self['layer_name'].value}'.")
self['layer_add'].process(submit)
self.editor.session['action'] = None
self.editor.session['action_data'] = None

elif submit == 'edit_choose':
self.editor.session['action'] = 'plot.edit.layer'
self.editor.session['action'] = 'plot.layer_edit.layer'
self.editor.session['action_data'] = {
'layer_name': self['layer_name'].value,
}
flash(f"Now editing layer '{self['layer_name'].value}'.")

elif submit == 'edit_save':
old_name = self.editor.session['action_data']['layer_name']
new_name = self['layer_name'].value

if old_name != new_name:
self.editor.dataset.plot_data['layers'][new_name] = self.editor.dataset.plot_data['layers'][old_name]
del(self.editor.dataset.plot_data['layers'][old_name])
flash(f"Renamed layer '{old_name}' to '{new_name}'.")

self.editor.dataset.plot_data['layers'][new_name]['x'] = self['layer_x'].value
self.editor.dataset.plot_data['layers'][new_name]['y'] = self['layer_y'].value

flash("Saved layer modifications.")
self['layer_edit'].process(submit)
self.editor.session['action'] = None
self.editor.session['action_data'] = None

elif submit == 'delete':
n = 0
@@ -154,6 +140,33 @@ class EditorPlotActionControl(poobrains.form.Fieldset):
self.editor.session['action'] = None
self.editor.session['action_data'] = None

elif submit == 'apply_kind':

old_plot_kind = self.editor.dataset.plot_data.get('kind', 'ScatterPlot')
old_plot_cls = poobrains.analysis.visualization.Plot.class_children_keyed()[old_plot_kind]

new_plot_kind = self['kind'].value
new_plot_cls = poobrains.analysis.visualization.Plot.class_children_keyed()[new_plot_kind]

self.editor.dataset.plot_data['kind'] = new_plot_kind

flash(f"Changed plot kind to {new_plot_kind}.")

if not old_plot_cls.editor_layer_fieldset is new_plot_cls.editor_layer_fieldset:
self.editor.dataset.plot_data['layers'].clear()
flash("Removed old layer information since it's not compatible to the new plot kind.", 'warning')

self.editor.session['action'] = None
self.editor.session['action_data'] = None

elif submit == 'apply_options':

self['plot_options'].process(submit)
flash("Applied plot options.")

self.editor.session['action'] = None
self.editor.session['action_data'] = None

return redirect(self.editor.instance.url('edit'))

class EditorDatasetNew(poobrains.form.Fieldset):
@@ -395,9 +408,7 @@ class EditorDatasetJoin(poobrains.form.Fieldset):
other = source.load(**parameters)

self.editor.session['action'] = 'join.finalize'
self.editor.session['action_data'] = {
'other': other.to_dict(whole=True),
}
self.editor.session['action_data']['other'] = other.to_dict(whole=True)

else:
flash("Internal bleeding detected.", 'error')


+ 0
- 32
poobrains/analysis/util.py View File

@@ -3,7 +3,6 @@ import pandas
import shapely.geometry
import geopandas
import geojson
import scss


from poobrains import app
@@ -196,34 +195,3 @@ def dict_to_data(rough_data):
data[column_name]['observations'][idx] = observation

return data

def get_highlight_color():

cc = app.scss_compiler.make_compilation()
#cc.add_source(scss.compiler.SourceFile.from_filename('colors.scss'))
cc.add_source(scss.compiler.SourceFile.from_string("@import 'colors.scss'"))
app.scss_compiler.call_and_catch_errors(cc.run)
cc.rules[0].namespace._variables
scss_color = cc.rules[0].namespace.variables['$color-highlight']
return poobrains.svg.Color(red=scss_color.rgb[0] / 255.0, green=scss_color.rgb[1] / 255.0, blue=scss_color.rgb[2] / 255.0, alpha=float(scss_color.alpha))

def create_palette(length):
if callable(app.config['PALETTE_FUNCTION']):
return app.config['PALETTE_FUNCTION'](get_highlight_color(), length)
return default_palette(get_highlight_color(), length)

def default_palette(base_color, length):

palette = []
for i in range(0, length):

color = base_color.clone()
if i % 2 != 0:
color.hue += 180 # make this a complementary palette */
color.hue += (180/length) * i
palette.append(color)

return palette



+ 510
- 29
poobrains/analysis/visualization.py View File

@@ -1,14 +1,34 @@
# -*- coding: utf-8 -*-

import math
import pyproj
import geopandas

import poobrains

from poobrains import app, locked_cached_property
from poobrains import app, flash, abort, locked_cached_property, Markup
from . import util


# Hacky but is only executed on boot and gives us all projections that don't need extra params
projection_choices = []
for proj_name, proj_title in pyproj.pj_list.items():
try:
pyproj.Proj(f"+proj={proj_name}")
except pyproj.exceptions.CRSError:
pass
else:
projection_choices.append((proj_name, proj_title))


@app.expose('/svg/plot')
class Plot(poobrains.svg.SVG):

__supported_dtypes__ = []

editor_fieldset = None # used to display a form to edit plot options
editor_layer_fieldset = None # used to display a form to add/edit layers specific to this plot type

padding = None
width = None
height = None
@@ -30,15 +50,13 @@ class Plot(poobrains.svg.SVG):

def __init__(self, handle=None, mode=None, dataset=None, layers=None, **kwargs):

super(Plot, self).__init__(handle=handle, mode=mode, **kwargs)
super().__init__(handle=handle, mode=mode, **kwargs)

if handle is None and dataset is None:
abort(404, "No dataset selected")
self.padding = app.config['SVG_PLOT_PADDING']
#self.plot_width = app.config['SVG_PLOT_WIDTH']
self.plot_width = 100 - 2 * self.padding
#self.plot_height = app.config['SVG_PLOT_HEIGHT']
self.plot_height = self.plot_width
self.description_height = app.config['SVG_PLOT_DESCRIPTION_HEIGHT']
self.description_y = 100 - self.description_height
@@ -52,40 +70,172 @@ class Plot(poobrains.svg.SVG):
self.dataset = poobrains.analysis.data.load_dataset(handle)

if len(self.dataset) == 0:
raise ValueError("Empty Dataset can not be visualized")

if not layers is None:
self.layers = layers

elif isinstance(self.dataset.plot_data, dict):
self.layers = self.dataset.plot_data['layers']
raise ValueError("Empty Dataset can't be visualized")

else:
self.layers = {}
if len(self.dataset) >= 2: # take first two columns as x and y axis, respectively
iter_keys = iter(self.dataset.keys())
x = next(iter_keys)
y = next(iter_keys)
self.layers[f'{x}-{y}'] = {
'x': x,
'y': y
}

else: # len(dataset) == 1; take only column as y with index as x
y = next(iter(self.dataset.keys()))
self.layers[f'{y}'] = {
'y': y,
}
self.layers = layers

self.preprocessed_data = None

@property
def length(self):
return max([len(column['observations']) for column in self.dataset.values()])

class XYFieldset(poobrains.form.Fieldset):
def __init__(self, plot_cls, editor, **kwargs):

super().__init__(**kwargs)

self.plot_cls = plot_cls
self.editor = editor

scale_choices = [('linear', 'linear'), ('logarithmic', 'logarithmic')]

self['scale_x'] = poobrains.form.fields.Select(
label='Scale Y',
choices=scale_choices,
help_text="How to scale values on the X-axis"
)

self['scale_y'] = poobrains.form.fields.Select(
label='Scale Y',
choices=scale_choices,
help_text="How to scale values on the X-axis"
)

class XYLayerFieldset(poobrains.form.Fieldset):

def __init__(self, plot_cls, editor, **kwargs):

super().__init__(**kwargs)
self.plot_cls = plot_cls
self.editor = editor
visualizable_columns = self.editor.dataset.columns_of_dtype(plot_cls.__supported_dtypes__)
column_choices = []
for column_name, column in visualizable_columns.items():
column_choices.append((column_name, column['title'] or column_name))

self['layer_name'] = poobrains.form.fields.Text(
label="Layer name",
help_text='machine readable ([a-z], [0-9], -)',
validators=[poobrains.form.validators.valid_name],
required=True
)

self['column_x'] = poobrains.form.fields.Select(
label="X-axis column",
choices=column_choices,
help_text="Which column to use for the X-axis of the plot."
)

self['column_y'] = poobrains.form.fields.Select(
label="Y-axis column",
choices=column_choices,
help_text="Which column to use for the Y-axis of the plot."
)
if isinstance(self.editor.session['action_data'], dict) and 'layer_name' in self.editor.session['action_data']:
self['layer_name'].value = self.editor.session['action_data']['layer_name']
if self['layer_name'].value in self.editor.dataset.plot_data['layers']:
self['column_x'].value = self.editor.dataset.plot_data['layers'][self['layer_name'].value]['x']
self['column_y'].value = self.editor.dataset.plot_data['layers'][self['layer_name'].value]['y']

def validate(self, submit):

layer_name = self['layer_name'].value
column_x = self['column_x'].value
column_y = self['column_y'].value

errors = poobrains.errors.CompoundError()

if column_x is None:
errors.append(poobrains.errors.ValidationError("You must choose an X-column for this layer.", self['column_x']))

if column_y is None:
errors.append(poobrains.errors.ValidationError("You must choose an Y-column for this layer.", self['column_y']))

if submit == 'add':
if layer_name in self.editor.dataset.plot_data['layers']:
errors.append(poobrains.errors.ValidationError(f"Layer with name '{layer_name}' already exists!", self['layer_name']))

elif submit == 'edit_save':

old_name = self.editor.session['action_data']['layer_name']
if layer_name != old_name and layer_name in self.editor.dataset.plot_data['layers']:
errors.append(poobrains.errors.ValidationError(f"Layer with name '{layer_name}' already exists!", self['layer_name']))

if len(errors):
raise errors

def process(self, submit):

layer_name = self['layer_name'].value
column_x = self['column_x'].value
column_y = self['column_y'].value

if submit == 'add':
self.editor.dataset.plot_data['layers'][layer_name] = {
'x': column_x,
'y': column_y,
}
flash(f"Added layer '{layer_name}'.")


elif submit == 'edit_save':
old_name = self.editor.session['action_data']['layer_name']
del(self.editor.dataset.plot_data['layers'][old_name])
self.editor.dataset.plot_data['layers'][layer_name] = {
'x': column_x,
'y': column_y,
}
flash(f"Saved layer '{layer_name}'.")



@app.expose('/svg/scatterplot')
class ScatterPlot(Plot):
__supported_dtypes__ = [
'int32',
'int64',
'uint32',
'uint64',
'float32',
'float64',
'datetime64'
]

editor_fieldset = XYFieldset
editor_layer_fieldset = XYLayerFieldset


def __init__(self, handle=None, mode=None, dataset=None, layers=None, **kwargs):

super().__init__(handle=handle, mode=mode, dataset=dataset, layers=layers, **kwargs)

if self.layers is None:

if isinstance(self.dataset.plot_data, dict):
self.layers = self.dataset.plot_data['layers']

else:
self.layers = {}
if len(self.dataset) >= 2: # take first two columns as x and y axis, respectively
iter_keys = iter(self.dataset.keys())
x = next(iter_keys)
y = next(iter_keys)
self.layers[f'{x}-{y}'] = {
'x': x,
'y': y
}

else: # len(dataset) == 1; take only column as y with index as x; I don't think this is implemented yet…
y = next(iter(self.dataset.keys()))
self.layers[f'{y}'] = {
'y': y,
}

def preprocess_data(self):

index = self.dataset.complete_index()
@@ -142,7 +292,7 @@ class ScatterPlot(Plot):
def palette(self):

named_palette = {}
palette = util.create_palette(len(self.layers))
palette = poobrains.svg.palettes.create_palette(len(self.layers))

for idx, (layer_name, layer) in enumerate(self.preprocessed_data.items()):
if not layer['color'] is None:
@@ -337,6 +487,337 @@ class BarChart(ScatterPlot):
return (self.plot_width - self.padding) / self.length - 1


class MapFieldset(poobrains.form.Fieldset):
def __init__(self, plot_cls, editor, **kwargs):
super().__init__(**kwargs)

self.plot_cls = plot_cls
self.editor = editor

self['projection'] = poobrains.form.fields.Select(
label="Map projection",
choices=projection_choices,
)

if 'options' in self.editor.dataset.plot_data:
self['projection'].value = self.editor.dataset.plot_data['options'].get('projection')

def validate(self, submit):
pass

def process(self, submit):
if submit == 'apply_options':
self.editor.dataset.plot_data['options'] = {
'projection': self['projection'].value
}


class MapLayerFieldset(poobrains.form.Fieldset):

def __init__(self, plot_cls, editor, **kwargs):

super().__init__(**kwargs)
self.plot_cls = plot_cls
self.editor = editor
visualizable_columns = self.editor.dataset.columns_of_dtype(plot_cls.__supported_dtypes__)
column_choices = []
for column_name, column in visualizable_columns.items():
column_choices.append((column_name, column['title'] or column_name))

self['layer_name'] = poobrains.form.fields.Text(
label="Layer name",
help_text='machine readable ([a-z], [0-9], -)',
validators=[poobrains.form.validators.valid_name],
required=True
)

self['column_geometry'] = poobrains.form.fields.Select(
label="Geometry column",
choices=column_choices,
help_text="The colum which holds the geometry to be rendered."
)

self['column_chloropleth_a'] = poobrains.form.fields.Select(
label="Chloropleth column",
choices=column_choices,
help_text="Select a column to be used to determine the coloring of map features."
)

self['column_chloropleth_b'] = poobrains.form.fields.Select(
label="Chloropleth column (second)",
choices=column_choices,
help_text="Second column used to color map features, will lead to a bimorphic color palette."
)

if isinstance(self.editor.session['action_data'], dict) and 'layer_name' in self.editor.session['action_data']:
self['layer_name'].value = self.editor.session['action_data']['layer_name']
if self['layer_name'].value in self.editor.dataset.plot_data['layers']:
layer_name = self['layer_name'].value
layer_info = self.editor.dataset.plot_data['layers'][layer_name]
self['column_geometry'].value = layer_info['geometry']
self['column_chloropleth_a'].value = layer_info['chloropleth_a']
self['column_chloropleth_b'].value = layer_info['chloropleth_b']

def validate(self, submit):

errors = poobrains.errors.CompoundError()

if submit == 'add':
if layer_name in self.editor.dataset.plot_data['layers']:
errors.append(poobrains.errors.ValidationError(f"Layer with name '{layer_name}' already exists!", self['layer_name']))

elif submit == 'edit_save':

old_name = self.editor.session['action_data']['layer_name']
if layer_name != old_name and layer_name in self.editor.dataset.plot_data['layers']:
errors.append(poobrains.errors.ValidationError(f"Layer with name '{layer_name}' already exists!", self['layer_name']))

if len(errors):
raise errors

def process(self, submit):

layer_name = self['layer_name'].value

if submit == 'add':
self.editor.dataset.plot_data['layers'][layer_name] = {
'geometry': self['column_geometry'].value,
'chloropleth_a': self['column_chloropleth_a'].value,
'chloropleth_b': self['column_chloropleth_b'].value,
}
elif submit == 'edit_save':
old_name = self.editor.session['action_data']['layer_name']
del(self.editor.dataset.plot_data['layers'][old_name])
self.editor.dataset.plot_data['layers'][layer_name] = {
'geometry': self['column_geometry'].value,
'chloropleth_a': self['column_chloropleth_a'].value,
'chloropleth_b': self['column_chloropleth_b'].value,
}

@app.expose('/svg/map')
class Map(Plot):
pass
__supported_dtypes__ = ['geometry']

editor_fieldset = MapFieldset
editor_layer_fieldset = MapLayerFieldset

def __init__(self, handle=None, mode=None, dataset=None, layers=None, projection=None, **kwargs):

super().__init__(handle=handle, mode=mode, dataset=dataset, layers=layers, **kwargs)

if self.layers is None:
if not 'layers' in self.dataset.plot_data:
raise poobrains.errors.ExposedError(f"No plot layer information for dataset '{dataset.title}'.")
self.layers = self.dataset.plot_data['layers']

if projection is None:
if 'options' in self.dataset.plot_data and 'projection' in self.dataset.plot_data['options']:
projection = self.dataset.plot_data['options']['projection']
else:
projection = 'webmerc' # Web Pseudo Mercator. epsg:3857, ""WGS84""
self.projection = pyproj.Proj(f"+proj={projection}")

self.bbox = None
self.bbox_projected = None
self.reference_span_x = None
self.reference_span_y = None
self.aspect_ratio = None
def normalize_coord(self, coord):

x, y = self.projection(coord[0], coord[1])

x -= self.bbox_projected[0][0]
x /= self.reference_span_x

y -= self.bbox_projected[0][1]
y /= self.reference_span_y

x *= self.width
y *= 100

y *= -1 # y axes of geodata and SVG are inversely oriented
return (x, y)

def preprocess_data(self):

self.bbox = self.find_bbox()

self.bbox_projected = [
self.projection(self.bbox[0][0], self.bbox[0][1]),
self.projection(self.bbox[1][0], self.bbox[1][1])
]

self.reference_span_x = self.bbox_projected[1][0] - self.bbox_projected[0][0]
self.reference_span_y = self.bbox_projected[0][1] - self.bbox_projected[1][1]
self.aspect_ratio = self.reference_span_x / self.reference_span_y
self.width = 100 * self.aspect_ratio

self.preprocessed_data = {}

for layer_name, layer_info in self.layers.items():

column_geometry = self.dataset[layer_info['geometry']]

column_chloropleth_a = layer_info['chloropleth_a']
column_chloropleth_b = layer_info['chloropleth_b']

if not column_chloropleth_a is None:
column_chloropleth_a = self.dataset[column_chloropleth_a]

if not column_chloropleth_b is None:
column_chloropleth_b = self.dataset[column_chloropleth_b]

if not column_chloropleth_a is None and not column_chloropleth_b is None:
title = f"{column_chloropleth_a['title']} vs. {column_chloropleth_b['title']}"
palette = poobrains.svg.palettes.bimorphic_blend

elif not column_chloropleth_a is None:
title = column_chloropleth_a['title']
palette = poobrains.svg.palettes.monomorphic_value
else:
title = column_geometry['title']
palette = poobrains.svg.palettes.contrast_neighbors
prepro_layer = {
'title': title,
'palette': palette,
'geometries': [],
}

for idx, feature in column_geometry['observations'].items():

prepro_feature = self.preprocess_geometry(feature)
prepro_layer['geometries'].append(prepro_feature)

self.preprocessed_data[layer_name] = prepro_layer

def merge_bboxes(self, a, b):

return [
[
a[0][0] if a[0][0] < b[0][0] else b[0][0],
a[0][1] if a[0][1] > b[0][1] else b[0][1],
],[
a[1][0] if a[1][0] > b[1][0] else b[1][0],
a[1][1] if a[1][1] < b[1][1] else b[1][1]
]
]

def merge_bbox_with_point(self, bbox, coord):
bbox = bbox.copy()
if coord[0] < bbox[0][0]:
bbox[0][0] = coord[0]

if coord[0] > bbox[1][0]:
bbox[1][0] = coord[0]

if coord[1] < bbox[0][1]:
bbox[0][1] = coord[1]

if coord[1] > bbox[1][1]:
bbox[1][1] = coord[1]

return bbox

def find_bbox(self):
bbox = [[180, -80], [-180, 80]]

for layer_name, layer_info in self.layers.items():

column_geometry = self.dataset[layer_info['geometry']]
gs = geopandas.GeoSeries(column_geometry['observations'])
bbox = self.merge_bboxes(bbox, [
[
min(gs.bounds['minx']), # left
max(gs.bounds['maxy']), # top
],[
max(gs.bounds['maxx']), # right
min(gs.bounds['miny']), # bottom
]
])

return bbox

def preprocess_geometry(self, geometry):

prepro_geometry = {
'type': geometry.type,
}

if geometry.type in ('GeometryCollection', 'MultiPoint', 'MultiLineString', 'MultiPolygon'):
geometries = []
for geometry in geometry:
prepro_sub_geometry = self.preprocess_geometry(geometry)
geometries.append(prepro_sub_geometry)

prepro_geometry['geometries'] = geometries

elif geometry.type in ('Point', 'LineString'):
normalized_coords = []
for coord in geometry.coords:
normalized_coords.append(self.normalize_coord(coord))
prepro_geometry['coords'] = normalized_coords

elif geometry.type =='Polygon':
exterior = []
for coord in geometry.exterior.coords:
exterior.append(self.normalize_coord(coord))
prepro_geometry['exterior'] = exterior

interiors = []
for interior in geometry.interiors:
normalized_interior = []
for coord in interior.coords:
normalized_interior.append(self.normalize_coord(coord))
interiors.append(normalized_interior)
prepro_geometry['interiors'] = interiors

return prepro_geometry

def render_geometry(self, geometry):
"""
"""

r = ""
if geometry['type'] == 'Point':
r += f'<use href="#marker" x="{geometry["coords"][0][0]}" y="{geometry["coords"][0][1]}" />'
elif geometry['type'] == 'LineString':
for idx in range(0, len(geometry['coords']) - 1):
line_start = geometry['coords'][idx]
line_end = geometry['coords'][idx + 1]
r += f'<line x1="{line_start[0]}" y1="{line_start[1]}" x2="{line_end[0]}" y2="{line_end[0]}" />'
elif geometry['type'] =='Polygon':
r += f'<path d="M {geometry["exterior"][0][0]} {geometry["exterior"][0][1]}' # move "cursor" to first coordinate
for coord in geometry['exterior'][1:-1]: # first and last coordinate are identical
r += f' L {coord[0]} {coord[1]}' # make a line to the next coordinate
r+= ' z ' # close linering

if len(geometry['interiors']):
for interior in geometry['interiors']:
r += f' M {interior[0][0]} {interior[0][1]}' # move cursor to first coordinate of interior ring
for coord in interior[1:-1]:
r += f' L {coord[0]} {coord[1]}' # make a line ot the next coordinate
r+= ' z ' # close interior linering

r += '" />'

elif geometry['type'] in ('GeometryCollection', 'MultiPoint', 'MultiLineString', 'MultiPolygon'):
for sub_geometry in geometry['geometries']:
r += self.render_geometry(sub_geometry)

return Markup(r)

def render(self, mode='teaser'):

if mode in ('inline', 'raw'):
self.preprocess_data()
#self.normalize_data()

return super().render(mode=mode)

+ 0
- 2
poobrains/defaults.py View File

@@ -20,8 +20,6 @@ CERT_MAX_LIFETIME = 60 * 60 * 24 * 365 # allow 1 year validity period for client

PERMANENT_SESSION_LIFETIME = datetime.timedelta(minutes=15)

PALETTE_FUNCTION = None

#SVG_PLOT_HEIGHT = 50 # height of svg plots
SVG_PLOT_PADDING = 8 # padding around an svg plot
SVG_PLOT_DESCRIPTION_HEIGHT = 80


+ 33
- 10
poobrains/storage/fields.py View File

@@ -107,7 +107,7 @@ class ForeignKeyChoice(poobrains.form.fields.Text, metaclass=poobrains.form.fiel
poobrains.form.fields.ForeignKeyChoice = ForeignKeyChoice


class Field(poobrains.helpers.ChildAware):
class Field(peewee.Field, poobrains.helpers.ChildAware):

form_widget = poobrains.form.fields.Text
type = poobrains.form.types.STRING
@@ -311,22 +311,45 @@ class SerializedFieldAccessor(peewee.FieldAccessor):
def serialize_preprocess(self, value):
return value

class JSONFieldAccessor(SerializedFieldAccessor):

class SerializedField(Field):

__casts__ = {
'serializer': lambda x: x,
'deserializer': lambda x: x
}

def python_value(self, value):
if not value is None:
return self.python_postprocess(self.__casts__['deserializer'](value))
return value

def python_postprocess(self, value):
return value

def db_value(self, value):
if not value is None:
return self.__casts__['serializer'](self.db_preprocess(value))

def db_preprocess(self, value):
return value

class JSONField(SerializedField):

__casts__ = {
'serializer': json.dumps,
'deserializer': json.loads,
'serializer': json.dumps
}

class JSONField(TextField):
accessor_class = JSONFieldAccessor
form_widget = poobrains.form.fields.File
type = TextField.type
field_type = TextField.field_type

class BSONField(SerializedField):

class BSONFieldAccessor(SerializedFieldAccessor):
__casts__ = {
'serializer': bson.dumps,
'deserializer': bson.loads,
'serializer': bson.dumps
}

class BSONField(BlobField):
accessor_class = BSONFieldAccessor
type = BlobField.type
field_type = BlobField.field_type

+ 80
- 0
poobrains/svg/__init__.py View File

@@ -0,0 +1,80 @@
# -*- coding: utf-8 -*-

import os
import collections
import scss

from poobrains import app, Markup, Response

import poobrains.helpers
import poobrains.auth

from . import color
from . import palettes

class SVG(poobrains.auth.Protected):
handle = None # needed so that app.expose registers a route with extra param, this is kinda hacky…
_css_cache = None
class Meta:

modes = collections.OrderedDict([
('teaser', 'read'),
('full', 'read'),
('raw', 'read'),
('inline', 'read')
])
style = None

def __init__(self, handle=None, mode=None, **kwargs):

super(SVG, self).__init__(**kwargs)

self.handle = handle
if not app.debug and self.__class__._css_cache != None:
self.style = self.__class__._css_cache
else:
self.style = Markup(app.scss_compiler.compile_string("@import 'svg';"))
self.__class__._css_cache = self.style
def templates(self, mode=None):

r = super(SVG, self).templates(mode=mode)
return ["svg/%s" % template for template in r]

def instance_url(self, mode='full', quiet=False, **url_params):
url_params['handle'] = self.handle

return super(SVG, self).instance_url(mode=mode, quiet=quiet, **url_params)


@poobrains.helpers.themed
def view(self, mode=None, handle=None):

if mode == 'raw':
response = Response(self.render('raw'))
response.headers['Content-Type'] = u'image/svg+xml'
response.headers['Content-Disposition'] = u'filename="%s.svg"' % self.__class__.__name__
# Disable "public" mode caching downstream (nginx, varnish) in order to hopefully not leak restricted content
response.cache_control.public = False
response.cache_control.private = True
response.cache_control.max_age = app.config['CACHE_LONG']

return response
else:
return poobrains.helpers.ThemedPassthrough(super(SVG, self).view(mode=mode, handle=handle))


@app.before_first_request
def register_svg_raw():
for cls in set(SVG.class_children()):
rule = os.path.join("/svg/", cls.__name__.lower(), '<handle>', 'raw')
app.site.add_view(cls, rule, mode='raw')

poobrains/svg.py → poobrains/svg/color.py View File

@@ -1,15 +1,8 @@
# -*- coding: utf-8 -*-

import os
import collections
import colorsys
import string
import scss

from poobrains import app, Markup, Response

import poobrains.helpers
import poobrains.auth
import statistics # for mean
import colorsys

class Color(object):

@@ -169,18 +162,51 @@ class Color(object):

def blend(self, other, mode='normal'):

if self.alpha != 1.0: # no clue how to blend with a translucent bottom layer
self.red = self.red * self.alpha
self.green = self.green * self.alpha
self.blue = self.blue * self.alpha

self.alpha = 1.0

if mode == 'normal':
own_influence = 1.0 - other.alpha
self.red = (self.red * own_influence) + (other.red * other.alpha)
self.green = (self.green * own_influence) + (other.green * other.alpha)
self.blue = (self.blue * own_influence) + (other.blue * other.alpha)
if self.alpha >= other.alpha:
other_influence = other.alpha / self.alpha
own_influence = 1.0 - other_influence
else:
own_influence = self.alpha / other.alpha
other_influence = 1.0 - own_influence

self.red = (self.red * own_influence) + (other.red * other_influence)
self.green = (self.green * own_influence) + (other.green * other_influence)
self.blue = (self.blue * own_influence) + (other.blue * other_influence)
self.alpha = statistics.mean([self.alpha, other.alpha])

elif mode == 'add':
self.red += other.red
self.green += other.green
self.blue += other.blue
self.alpha += other.alpha

elif mode == 'subtract':
self.red -= other.red
self.green -= other.green
self.blue -= other.blue
self.alpha -= other.alpha

elif mode == 'multiply':
self.red *= other.red
self.green *= other.green
self.blue *= other.blue
self.alpha *= other.alpha

elif mode == 'divide':
self.red /= other.red
self.green /= other.green
self.blue /= other.blue
self.alpha /= other.alpha

elif mode == 'hue':
self.hue = other.hue

elif mode == 'saturation':
self.saturation = other.saturation

elif mode == 'value':
self.value = other.value

def lighten(self, other):

@@ -232,70 +258,3 @@ class Color(object):
self.red = red
self.green = green
self.blue = blue

class SVG(poobrains.auth.Protected):
handle = None # needed so that app.expose registers a route with extra param, this is kinda hacky…
_css_cache = None
class Meta:

modes = collections.OrderedDict([
('teaser', 'read'),
('full', 'read'),
('raw', 'read'),
('inline', 'read')
])
style = None

def __init__(self, handle=None, mode=None, **kwargs):

super(SVG, self).__init__(**kwargs)

self.handle = handle
if not app.debug and self.__class__._css_cache != None:
self.style = self.__class__._css_cache
else:
self.style = Markup(app.scss_compiler.compile_string("@import 'svg';"))
self.__class__._css_cache = self.style
def templates(self, mode=None):

r = super(SVG, self).templates(mode=mode)
return ["svg/%s" % template for template in r]

def instance_url(self, mode='full', quiet=False, **url_params):
url_params['handle'] = self.handle

return super(SVG, self).instance_url(mode=mode, quiet=quiet, **url_params)


@poobrains.helpers.themed
def view(self, mode=None, handle=None):

if mode == 'raw':
response = Response(self.render('raw'))
response.headers['Content-Type'] = u'image/svg+xml'
response.headers['Content-Disposition'] = u'filename="%s.svg"' % self.__class__.__name__
# Disable "public" mode caching downstream (nginx, varnish) in order to hopefully not leak restricted content
response.cache_control.public = False
response.cache_control.private = True
response.cache_control.max_age = app.config['CACHE_LONG']

return response
else:
return poobrains.helpers.ThemedPassthrough(super(SVG, self).view(mode=mode, handle=handle))


@app.before_first_request
def register_svg_raw():
for cls in set(SVG.class_children()):
rule = os.path.join("/svg/", cls.__name__.lower(), '<handle>', 'raw')
app.site.add_view(cls, rule, mode='raw')

+ 94
- 0
poobrains/svg/palettes.py View File

@@ -0,0 +1,94 @@
# -*- coding: utf-8 -*-

import scss

from poobrains import app

from . import color

def get_highlight_color():

cc = app.scss_compiler.make_compilation()
#cc.add_source(scss.compiler.SourceFile.from_filename('colors.scss'))
cc.add_source(scss.compiler.SourceFile.from_string("@import 'colors.scss'"))
app.scss_compiler.call_and_catch_errors(cc.run)
cc.rules[0].namespace._variables
scss_color = cc.rules[0].namespace.variables['$color-highlight']
return color.Color(red=scss_color.rgb[0] / 255.0, green=scss_color.rgb[1] / 255.0, blue=scss_color.rgb[2] / 255.0, alpha=float(scss_color.alpha))

palettes = {}
def palette(func):
palettes[func.__name__] = func
return func


@palette
def contrast_neighbors(base_color: color.Color, length: int) -> list:

palette = []
for i in range(0, length):

color = base_color.clone()
if i % 2 != 0:
color.hue += 180 # make this a complementary palette */
color.hue += (180/length) * i
palette.append(color)

return palette

#@palette
#def hue_progression(base_color,

@palette
def monomorphic_value(base_color: color.Color, length: int) -> list:

if base_color.value < 0.25:
raise ValueError(f"Base color {base_color} .value too low, must be >= 0.25")

palette = [base_color]

step = (base_color.value -0.05) / (length - 1)

for multiplier in range(1, length):
variant_color = base_color.clone()
variant_color.value -= step * multiplier
palette.append(variant_color)

return palette

@palette
def bimorphic_blend(base_color: color.Color, length: int) -> list:

palette = [base_color]

complementary_color = base_color.clone()
complementary_color.hue += 180

step = 1.0 / (length - 1)
for multiplier in range(0, length - 1):
complementary_blend = complementary_color.clone()
complementary_blend.alpha = step * multiplier
variant_color = base_color.clone()
variant_color.blend(complementary_blend)
palette.append(variant_color)

return palette

def create_palette(length):
return contrast_neighbors(get_highlight_color(), length)

@app.admin.route('/palettes')
def sample_palettes():

output = ''

for name, func in palettes.items():
hl_color = get_highlight_color()
palette = func(hl_color, 5)
output += f'<div><h2>{name}</h2>'
for color in palette:
output += f'<div style="display: inline-block; width: 5rem; height: 5rem; margin-right: 1rem; background:{color.hexrgb()};">&nbsp;</div>'
output += '</div>'

return output

+ 3
- 134
poobrains/themes/default/svg.scss View File

@@ -236,65 +236,6 @@ svg#checkbox {
}
}

/* ## COMMON STUFF FOR PLOTS AND MAPS */

.map .datapoint {

g.description {

transform: translateY(100vh);
transition: transform 0.3s linear;

.description-background {
fill: $color_background_dark;
}

.html {

& > div {
position: relative;
max-width: 70ex;
height: 100%;
margin: 0 auto;
background: $color_background_dark;

& > .content {
position: relative;
overflow-y: scroll;
padding: 8px;
height: 80%;
}
}

h1 {
margin: 0;
padding: 0;
font-size: 2em;
}

object, img {
max-width: 100%;
}
}

.fallback {

overflow-y: auto;

text {
fill: $color_font_light;
}
}
}

&:target { /* dataset descriptions should only be shown when they are referenced by #fragment */
g.description {
transform: translateY(0);
}
}

}

/* ## PLOTS ## */

#background {
@@ -511,81 +452,9 @@ html svg #background {
/* ## MAPS ## */

svg.map {

.geojson-render path {
stroke: $color_highlight;
stroke-width: 1px;
fill: $color_background_dark;
}

.terrain {

.ocean {
fill: darken($color_highlight, 45%);
}

/*.country {*/
path {

stroke: opacify($color_background_light, -20%);
stroke-width: 1px;
stroke-miterlimit: 1;
fill: opacify($color_background_dark, -20%);
transition: fill 0.3s linear, stroke 0.3s linear;

g,
path {
stroke-width: 0.1rem;
}
}
}

g.datapoints {


g.datapoint {

.marker,
.geojson-polygon {
transition: fill 0.3s, stroke 0.3s;
}

/* palettize data */
@for $i from 1 through $palette_size {

$color: nth($palette, $i);

&:nth-child(#{$i}n+#{$i}) {

.marker {
fill: desaturate($color, 20%);
}

.geojson-polygon {
fill: opacify($color, -65%);
stroke: opacify($color_background_light, -50%);
}


&:hover,
&:focus,
&:target {

.marker {
fill: $color;
stroke: $color_background_light;
}

.geojson-polygon {
fill: opacify($color, -55%);
stroke: opacify($color_background_light, -40%);
}
}
}
}
}
}
stroke: $color_highlight;
stroke-width: 1px;
fill: $color_background_dark;
}


@import 'svg-custom';

+ 1
- 1
poobrains/themes/default/svg/barchart-inline.jinja View File

@@ -1,4 +1,4 @@
{% extends 'svg/plot-inline.jinja' %}
{% extends 'svg/scatterplot-inline.jinja' %}
{% block datapoint scoped %}
<rect class="bar"
x="{{ datapoint['x'] }}%"


+ 0
- 1
poobrains/themes/default/svg/boxplot-raw.jinja View File

@@ -1 +0,0 @@
{% extends 'svg/plot-raw.jinja' %}

+ 1
- 1
poobrains/themes/default/svg/lineplot-inline.jinja View File

@@ -1,4 +1,4 @@
{% extends 'svg/plot-inline.jinja' %}
{% extends 'svg/scatterplot-inline.jinja' %}
{% block layer scoped %}

<a class="layer-link" href="#{{ layer['id'] }}-description">


+ 37
- 0
poobrains/themes/default/svg/map-inline.jinja View File

@@ -0,0 +1,37 @@
<svg
xmlns="http://www.w3.org/2000/svg"
version="1.1"
width="100%"
height="100%"

viewBox="0 0 100% 100%"
preserveAspectRatio="xMinYMin meet"
id="{{ content.dataset.ref_id }}"
class="plot {{ content.__class__.__name__.lower()}}"
>
{% if config.DEBUG %}<!-- {{ self._TemplateReference__context.name }} -->{% endif %}

<!-- a json representation of all visualized data, not used since there's a strict nojs policy, but included for your convenience -->
{{ content.dataset.render('json') }}

<style>
{{ content.style }}
</style>

{% include "svg/defs.jinja" %}

{% if config.DEBUG %}
<text x="0" y="15">BBOX: {{ content.bbox }}</text>
{% endif %}

{# beginning of layers #}
{% for layer_name, layer in content.preprocessed_data.items() %}
<svg id="{{ layer['id'] }}" class="layer" width="100%" viewBox="0 0 {{ content.width }} 100" preserveAspectRatio="xMinYMin meet">
{% block layer scoped %}
{% for geometry in layer['geometries'] %}
{{ content.render_geometry(geometry) }}
{% endfor %}
{% endblock %}
</svg>
{% endfor %}
</svg>

+ 2
- 38
poobrains/themes/default/svg/map-raw.jinja View File

@@ -1,38 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>

<svg
xmlns='http://www.w3.org/2000/svg'
xmlns:dc='http://purl.org/dc/elements/1.1/'
version='1.1'
width="{{ content.width }}"
height="{{ content.height + content.description_height }}"
viewBox='0 0 {{ content.width }} {{ content.height + content.description_height }}'
preserveAspectRatio="xMinYMin meet"
class="map"
>
<!-- bbox: {{ content.bbox }} -->

<style>
{{ content.style }}
</style>

{% include "svg/defs.jinja" %}

{% if content.terrain.geojson %}
<a href="#terrain"> <!-- / click to deselect datapoint[s] -->
<svg id="terrain" class="terrain">
<rect class="ocean" x="0" y="0" width="100%" height="100%" />
{{ content.render_geojson(content.terrain.geojson) }}
</svg> <!-- / .terrain -->
</a>
{% endif %}

{{ content.grid() }}

<g class="datapoints">
{% for datapoint in content.root_datapoints %}
{{ datapoint.render('raw', content.bbox) }}
{% endfor %}
</g> <!-- / .datapoints-->
</svg>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
{% extends 'svg/map-inline.jinja' %}

poobrains/themes/default/svg/plot-inline.jinja → poobrains/themes/default/svg/scatterplot-inline.jinja View File

@@ -20,6 +20,7 @@

{% include "svg/defs.jinja" %}

{% block defs %}
<defs>

<rect id="background" class="background" x1="0" y1="0" width="100%" height="100%" />
@@ -144,13 +145,14 @@
{# end of grid #}

</defs>
{% endblock %} {# end of block defs #}


<use href="#background" />

<svg class="plot-inner" x="{{ content.padding }}%" y="{{ content.padding }}%" width="{{ content.plot_width }}%" height="{{ content.plot_height }}%" viewBox="0 0 100% 100%" preserveAspectRatio="xMinYMin meet">
<svg class="plot-inner" x="{{ content.padding }}%" y="{{ content.padding }}%" width="{{ content.plot_width }}%" height="{{ content.plot_height }}%" viewBox="0 0 100% 100%" preserveAspectRatio="xMinYMin meet">

{% if content.has_grid %}<use href="#grid" />{% endif %}
{% if content.has_grid %}<use href="#grid" />{% endif %}

{# beginning of layers #}
{% for layer_name, layer in content.preprocessed_data.items() %}

poobrains/themes/default/svg/plot-raw.jinja → poobrains/themes/default/svg/scatterplot-raw.jinja View File

@@ -1,2 +1,2 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
{% extends 'svg/plot-inline.jinja' %}
{% extends 'svg/scatterplot-inline.jinja' %}

+ 6
- 2
poobrains/themes/default/svg/svg.jinja View File

@@ -2,7 +2,11 @@

{% block content %}

{{ content.render('inline') }}
{# <a href="{{ content.url('raw') }}" target="_blank">Save</a> #}
{% if mode == 'inline' %}
<span>You probably want to add {{ content.__class__.__name__.lower() }}-inline.jinja template.</span>
{% else %}
{{ content.render('inline') }}
{# <a href="{{ content.url('raw') }}" target="_blank">Save</a> #}
{% endif %}

{% endblock %}

Loading…
Cancel
Save