Edit on GitHub

starplast.celldiagram

An apicomplexan cell, colored from the same palette as the map.

A list of 27 compartment names is a legend; a parasite with its rhoptries filled in the color the rhoptries have on the map is a picture of where the genes are. This draws the second from the first, at draw time, from Window.color_of -- never from a second palette, because two palettes are two claims about what a color means and one of them will drift.

The artwork is UniProt's subcellular-location diagram (data/icons/Apicomplexa_cells.svg): 59 organelles, each a <g> carrying a UniProt SL identifier. So the drawing already speaks a controlled vocabulary and the work here is a mapping from hyperLOPIT compartment names onto SL codes -- COMPARTMENT_SL -- rather than any renaming of the artwork.

Three decisions this module implements, all of them forced by the data

Several hyperLOPIT classes share one organelle. Both rhoptry classes, both nucleus classes and the three plasma-membrane classes map to a single shape, which cannot carry three colors at once. The shape takes the color of whichever class is SELECTED, and says which one it is showing. With nothing selected it is neutral: filling it with whichever class sorts first would be a claim nobody made. Clicking it cycles through the classes that share it, saying each time which is now selected.

Some compartments have no organelle in the drawing. The proteasome classes and apical 1/2 are not in the artwork, and cytosol has no distinct shape worth clicking. They are listed beside the diagram rather than silently absent -- between them they are a large part of this proteome, and a compartment that vanishes from the legend reads as one that does not exist.

Absence stays gray. unassigned is 4,313 genes. It is never filled with a compartment color and never looks like a measurement.

COMPARTMENT_SL = {'rhoptries 1': 'SL0233', 'rhoptries 2': 'SL0233', 'micronemes': 'SL0163', 'dense granules': 'SL0281', 'apicoplast': 'SL0018', 'IMC': 'SL0362', 'mitochondrion - soluble': 'SL0173', 'mitochondrion - membranes': 'SL0171', 'Golgi': 'SL0132', 'ER': 'SL0095', 'ER 2': 'SL0095', 'nucleus - chromatin': 'SL0191', 'nucleus - non-chromatin': 'SL0191', 'nucleolus': 'SL0188', 'cytosol': 'SL0091', 'PM - integral': 'SL0039', 'PM - peripheral 1': 'SL0039', 'PM - peripheral 2': 'SL0039', 'tubulin cytoskeleton': 'SL0090'}
BACKGROUND_SL = frozenset({'SL0173', 'SL0171', 'SL0091', 'SL0191', 'SL0018'})
UNMAPPED_NOTE = 'no organelle in this drawing — the artwork has no ribosome, proteasome or apical complex, and its vesicles are specifically COPI and COPII'
NEUTRAL = (0.62, 0.62, 0.66)
FILL_GREY = '#d9d9d9'
LINE_GREY = '#8d8d8d'
def strip_metadata(svg: str) -> str:

Remove the artwork's hidden text and links.

UniProt ships each organelle with a <text> description, a <text> name and an <a> link to its own page, all visibility="hidden" -- and some of them nested INSIDE a <path>, where the SVG content model does not allow them. Qt's renderer says so, three times per render:

qt.svg: <input>:42:452: Could not add child element to parent element because the types
are incorrect.

It then draws the file correctly, so the warnings are noise -- but noise printed on every repaint of every organelle mask is a console nobody can read, and a real warning would be lost in it. None of this is drawn: it is metadata for a page that is not this one.

def neutralise(svg: str) -> str:

Strip the artwork's own colors down to grey fills and grey lines.

Every fill becomes one light grey and every stroke one mid-grey, so the drawing reads on a dark ground and on a light one, and so that ANY color in it is the selection. fill="none" is left alone: it is not a color, it is the absence of one, and filling those shapes in would turn the cell's internal outlines into solid blocks.

def icon_path() -> str:

Where the artwork lives, resolved the way every other data file is.

def available(path: str = '') -> bool:

Whether the drawing is there. It ships with the package; a missing file hides the diagram.

def groups_in(svg: str) -> set:

Every SL group the artwork actually contains.

Read from the file rather than trusted from the table above, because a mapping to a shape that is not in the drawing is a silent no-op -- the compartment would simply never color anything and nothing would say why.

def missing_from_drawing(compartments, svg: str = '') -> list:

Compartments with no organelle to color, in the order given.

Named rather than dropped. Between the proteasome classes, the apical classes and anything the artwork lacks, these are a large part of the proteome, and a compartment that vanishes from the legend reads as one that does not exist.

def group_span(svg: str, sl: str):

(start, end) of one SL group's markup, or None -- counting nested <g> as it goes.

A non-greedy <g id="SL...".*?</g> ends at the FIRST inner closing tag, and these groups nest: the rhoptry holds its membrane, the Golgi holds four sub-compartments. Matched that way, most organelles came back as a fragment -- which rendered as nothing, so eleven of the fourteen hit-test masks were silently empty and the coloring painted part of a shape.

def sharing(sl: str) -> list:

Every compartment drawn by one organelle, in the table's order.

What the click cycle steps through, and the reason a shared shape has to say which class it is currently showing.

CANVAS_GROUPS = ('SL0112', 'SL0243')
BACKGROUND_ID = 'path_1_'
def transparent_ground(svg: str) -> str:

Drop the artwork's own background so the panel's shows through.

Two things make the card: a white rectangle behind everything, and the groups that stand for the space OUTSIDE the cell, which are drawn as filled sheets across the whole canvas. Both go.

CREDIT_GROUP = 'sib_copyright'
def credit(svg: str) -> dict:

Who drew the cell and under what licence, read out of the artwork itself.

Read rather than written down here: the file embeds property="creator" and a CC licence link, and a hand-copied attribution is one that silently stops matching the file it credits. The artwork is CC BY, so the attribution is a condition of using it, not a courtesy -- which is why dropping the logo block from the drawing has to be paired with showing this.

def drop_credit(svg: str) -> str:

Remove the credit block from the drawing (the credit itself travels via credit).

def view_box(svg: str):

The artwork's own (x, y, width, height), or None. See CellDiagram.viewbox.

def portrait(svg: str) -> str:

Turn the cell upright, apical end -- the rhoptries -- at the top.

The artwork is drawn lying down, apex to the left, which is the orientation of a textbook figure and the wrong one for a tall panel: laid out flat in a 260-pixel column it is a strip a centimetre high. Rotated a quarter turn it fills the column, and it matches how the parasite is drawn in every paper about invasion -- apex first, because that is the end that goes in.

Done by wrapping the drawing in a rotation and swapping the viewBox rather than by editing coordinates: the artwork has 59 groups and several thousand paths, and touching them would break the SL ids that everything else here depends on.

def recolor(svg: str, fills: dict, neutral=(0.62, 0.62, 0.66)) -> str:

Set the fill of each SL group, returning the modified SVG text.

Done on the text at draw time rather than by shipping a recolored copy: there are four themes and the categorical palette changes with each, so any stored copy would be wrong for three of them and would drift from the map the moment a palette changed.

The fill is applied to the group AND its descendants' inline styles, because the artwork sets fill on the individual paths -- a fill on the group alone is overridden by every path in it and the diagram would come back gray while every test on the returned string passed.

def outline_only(svg: str, stroke: str = '#ffffff', width: float = 1.4) -> str:

Hollow every shape out and draw it in white line.

A cell drawn as outlines sits on whatever is behind it -- the panel, the theme, anything -- and the one filled thing in it is then unmistakably the selection. Filled shapes, however pale, are a card the diagram sits on.

Shapes that carried no stroke get one, because a shape whose fill is removed and which has no outline is simply gone: the nucleus and the cell body are both drawn as fills in this artwork.

class CellDiagram(PyQt6.QtWidgets.QWidget):

The parasite, filled from the map's own palette, clickable in both directions.

CellDiagram(parent=None, path: str = '')
def compartment_clicked(unknown):

pyqtSignal(*types, name: str = ..., revision: int = ..., arguments: Sequence = ...) -> PYQT_SIGNAL

types is normally a sequence of individual types. Each type is either a type object or a string that is the name of a C++ type. Alternatively each type could itself be a sequence of types each describing a different overloaded signal. name is the optional C++ name of the signal. If it is not specified then the name of the class attribute that is bound to the signal is used. revision is the optional revision of the signal that is exported to QML. If it is not specified then 0 is used. arguments is the optional sequence of the names of the signal's arguments.

path
credit
viewbox
svg_solid
svg
groups
color_of: dict
selected
def set_palette(self, color_of: dict, selected: str = ''):

Take the map's palette and the current selection, and redraw from them.

def fills(self) -> dict:

SL group -> color: the SELECTED compartment, and nothing else.

One colored organelle at a time, in the color that compartment has in the list and on the map. Coloring every organelle at once makes the diagram a second legend -- twenty-odd colors to read against twenty-odd names -- when what a person wants to know is where the thing they just clicked is. Everything else is grey, which is also what the map shows: the points of one compartment against a grey field.

def showing(self) -> str:

Which class the shared shape is currently showing, as a sentence, or "".

def renderer(self):

The SVG renderer for the current colors, built once per palette change.

def paintEvent(self, ev):

Draw the cell grey, then tint the selected organelle.

The drawing is hollow -- white outlines on nothing -- so it sits on whatever is behind it and the one FILLED thing in it is unmistakably the selection, in the color that compartment has in the list beside it.

Hollowing it is also what finally made it neutral. Every fill, stroke and gradient stop in this file can be set to grey and it still renders pink: 145 elements carry a colored class and eleven gradients cross-reference each other, and nothing in the document accounts for it. A shape with no fill has nothing to render in any color.

def local_to_widget(self, px: float, py: float):

A point in the artwork's own frame to widget pixels, through the rotation and the fit.

Defined beside its inverse so the two cannot drift: an off-by-a-quarter-turn hit test is invisible until someone clicks an organelle and selects its neighbour.

def widget_to_local(self, x: float, y: float):

Widget pixels back to the artwork's own frame -- the inverse of local_to_widget.

def masks(self):

Which pixels each organelle actually covers, at the current size.

Hit-testing used bounding boxes, and the boxes are useless here: every group contains hidden <text> blocks holding UniProt's description of that compartment, so the Golgi's box is 4,894 units wide in a 1,190-wide drawing. Clicking a rhoptry landed on whichever inflated box happened to win -- the proteasome, the plasma membrane, anything.

So the shapes are rendered. Each organelle is drawn alone into a small image and its ink recorded; the hidden text does not render, so the mask is exactly what a person sees. Built once per size and cached, because it costs one render per organelle.

def organelle_at(self, x: float, y: float) -> str:

The organelle under a widget point, or "".

Whichever drawn shape actually contains the pixel; where several do -- the nucleolus sits inside the nucleus -- the SMALLEST wins, because the small one is the thing being aimed at.

def mouseReleaseEvent(self, ev):

Click an organelle to select its compartment; click again to step to the next.

Cycling is what makes a shared shape usable: one shape stands for three plasma-membrane classes, and without a cycle two of them would be unreachable from the drawing.