tangrams / tangram

WebGL map rendering engine for creative cartography
https://tangram.city
MIT License
2.22k stars 290 forks source link

override colors for all `roads` sublayers, at once #576

Open burritojustice opened 7 years ago

burritojustice commented 7 years ago

I'd like to be able to assign colors to roads in any sublayer in a Mapzen basemap at the top level of the layer, without having to dive into each sublayer.

Doing so now is possible, but it assumes a detailed knowledge of the layer hierarchy which is a challenge for one Mapzen basemap style, never mind all of them.

Right now, it has to be done this way. (This assumes I have a function defining the color.

global:
    my_colors: ...

layers:
    roads:
        filter: {...}
        z-my_colors: global.my_colors
        major_road:
            z-my_colors: global.my_colors
            trunk_primary:
                z-my_colors: global.my_colors
                routes:
                    z-my_colors: global.my_colors
            secondary:
                z-my_colors: global.my_colors
            tertiary:
                z-my_colors: global.my_colors
                routes:
                    z-my_colors: global.my_colors
            ...

an example: https://mapzen.com/tangram/play/?scene=https%3A%2F%2Fmapzen.com%2Fapi%2Fscenes%2F22%2F862%2Fresources%2Fblank.yaml#15.0459/37.7598/-122.4131

~https://mapzen.com/tangram/play/?api=22%2F872#15.1500/37.7737/-122.4122~

It would be much simpler and less error prone to do something like this:

layers:
    roads:
        my_stuff_is_more_important:
            filter: {...}
            z-johns_colors: global.johns_colors

Note that I inadvertently got this to work -- in the example below, while the sublayer is major_road, the colors apply to all roads, regardless of the sublayer name.

https://mapzen.com/tangram/play/?scene=https%3A%2F%2Fmapzen.com%2Fapi%2Fscenes%2F22%2F872%2Fresources%2Fblank.yaml#15.0459/37.7598/-122.4131 ~https://mapzen.com/tangram/play/?api=22/872#14.4667/37.7773/-122.4108~

        major_road:
            z:
                early:
                    draw:
                        lines:
                            color: |
                                function(){
                                    var name = feature.name
                                    if (name.match(/Harrison/i)) {
                                       return 'blue'
                                        }
                                    if (name.match(/Howard/i)) {
                                       return 'lightgreen'
                                        }
                                    if (name.match(/Folsom/i)) {
                                       return 'orange'
                                        } 
                                    if (name.match(/Mission/i)) {
                                       return 'red'
                                        }  
                                    else {return 'black'}
                                }
                            # color: lightgreen
                            width: 2px
                            order: 500

I don't exactly know why this works. Note also that if you change z to a, some of the roads disappear, so this seems to be parsed in alphabetical order?

Anyway, a more stable method of doing this would be useful.

bcamper commented 7 years ago

cc @matteblair @meetar @nvkelso

(Side note, the example links in this issue aren't working.)

So yeah you've come up against a style precedence issue.

Tangram draw group specificity

How draw rules are resolved and how they were intended to be used:

  1. The draw groups in each layer tree are hierarchical: deeper (child or descendant layers) for the same draw group name override shallower ("higher up", parent or ancestor) ones.

The child layer overrides the parent layer, so features matching child will be yellow:

layers:
  parent:
    draw: { polygons: { color: red } }

    child:
      draw: { polygons: { color: yellow } }
  1. A feature can match multiple layer trees. For example, one tree of rules might be for specifying color, and another could be for specifying extrusion; this is useful when the filter logic is substantially different (if you could only match on one tree, there might be cases where you had to create rules for every combination of filters along two different "axes").
layers:
  # buildings are grey and un-extruded by default
  buildings:
    draw: { polygons: { color: gray, extrude: false } }

    # buildings with a `kind` property are red
    kind-buildings:
      filter: { kind: true }
      draw: { polygons: { color: red } }

    # tall buildings are extruded (but they might be gray or red)
    tall-buildings:
      filter: { height: { min: 100 } }
      draw: { polygons: { extrude: true } }

This gives 4 possible variants:

  1. A potential collision comes into play when multiple layer trees match, as above. In the example above, there's no potential for collision because each layer only modifies a single property and there's no overlap (one set of rules for color, another for extrude). What happens when multiple matching layer trees try to modify the same properties? The draw groups are still evaluated hierarchically, so the deeper rule in either tree will still win. But what about cases where the same property (e.g. color) is set in two parallel layers, at the same depth? There is no "right" answer, and so this is discouraged! But, it's also an unfortunate possibility given the syntax we allow. For consistency, they are evaluated in alphabetical order, so a z layer should win over an a layer at the same depth.

(You mentioned a case where it still seemed a deeper rule was overriding others elsewhere in the tree... we would need to look more closely at the example to see what's happening but I can't access it currently.)

!important

Phew OK so that's how it all works now. What you have encountered is a issue that most people familiar with CSS will recognize (in a slightly different form since Tangram layers aren't exactly like CSS selectors - see how they are resolved here) as the dreaded !important problem: if a CSS selector has an !important property, it will override other selectors that would usually have greater precedence.

This of course leads to a war of escalation (from the CSS page linked above):

How to override !important A) Simply add another CSS rule with !important, and either give the selector a higher specificity...

Eventually, multiple conflicting !important selectors will be resolved the same way that un-important ones are: the last one defined in the file will win (Tangram uses alphabetical because of differences in parsing pipeline, imports, etc.).

CSS !important is both widely frowned upon, and still used, particularly for "global override" type cases and/or those where you are modifying someone else's complex style... similar to the case you have where you are trying to override a specific aspect of an existing imported basemap:

You should use it when: A) Scenario one: You have a global CSS file that sets visual aspects of your site globally.

(^ I still think this is dubious but it's analogous to your case.)

What to do

Though I've been aware of the issue since we first added the ability to match multiple layer trees, I've long been hesitant to add syntax to address it, for all of the reasons it's problematic in CSS. At the same time, we've recently seen a few cases where the same problem is cropping up: if you want to globally override aspects of an imported basemap, you have to create a parallel set of rules for each "terminal" node in the tree (to ensure that your change is the deepest and overrides ancestors). This is cumbersome for us as-is and likely to be too complex for many external users (relies on a lot of style-specific knowledge, in addition to the copy-paste aspects).

If we were to add Tangram syntax to support this, here is what I have experimented with: a precedence property (similar to label priority) that is:

Here's an example:

layers:
  roads:
    # major roads are red
    major:
      filter: { kind: major_road }
      draw:
        lines:
          color: red
          width: 10px

    # major roads are blue
    minor:
      filter: { kind: minor_road }
      draw:
        lines:
           color: blue
           width: 5px

    # surprise! all roads are yellow
    surprise:
      draw:
        lines:
          precedence: 1
          color: yellow

This certainly opens up a new can of worms, but it provides a way to accommodate the particular use case we've discussed here. I actually have this working in a branch with minimal code changes, but it only really affects the order in which the draw groups in the matching layer trees are merged.

nvkelso commented 7 years ago

Would a larger or smaller precedence value "win"? And what is the default of that value when not specified?

matteblair commented 7 years ago

Great explanation of the issue here. For now I'll just acknowledge that I've read this and agree that there's a usability issue here - I don't have a solution to propose. I'll be chewing on the precedence idea and seeing if anything else comes to mind.