Search code examples
pythonplotcoordinatesbokehhexagonal-tiles

How to hack this Bokeh HexTile plot to fix the coords, label placement and axes?


Below is Bokeh 1.4.0 code that tries to draw a HexTile map of the input dataframe, with axes, and tries to place labels on each hex. I've been stuck on this for two days solid, reading bokeh doc, examples and github known issues, SO, Bokeh Discourse and Red Blob Games's superb tutorial on Hexagonal Grids, and trying code. (I'm less interested in raising Bokeh issues for the future, and far more interested in pragmatic workarounds to known limitations to just get my map code working today.) Plot is below, and code at bottom.

Here are the issues, in rough decreasing order of importance (it's impossible to separate the root-cause and tell which causes which, due to the way Bokeh handles glyphs. If I apply one scale factor or coord transform it fixes one set of issues, but breaks another, 'whack-a-mole' effect):

  1. The label placement is obviously wrong, but I can't seem to hack up any variant of either (x,y) coords or (q,r) coords to work. (I tried combinations of figure(..., match_aspect=True)), I tried 1/sqrt(2) scaling the (x,y)-coords, I tried Hextile(... size, scale) params as per redblobgames, e.g. size = 1/sqrt(3) ~ 0.57735).
  2. Bokeh forces the origin to be top left, and y-coords to increase as you go down, however the default axis labels show y or r as being negative. I found I still had to use p.text(q, -r, .... I suppose I have to manually patch the auto-supplied yaxis labels or TickFormatter to be positive.
  3. I use np.mgrid to generate the coord grid, but I still seem to have to assign q-coords right-to-left: np.mgrid[0:8, (4+1):0:-1]. Still no matter what I do, the hexes are flipped L-to-R
    • (Note: empty '' counties are placeholders to get the desired shape, hence the boolean mask [counties!=''] on grid coords. This works fine and I want to leave it as-is)
  4. The source (q,r) coords for the hexes are integers, and I use 'odd-r' offset coords (not axial or hexagonal coords). No matter what HexTile(..., size, scale) args I use, one or both dimensions in the plot is wrong or squashed. Or whether I include the 1/sqrt(2) factor in coord transform.
    • My +q-axis is east and my +r-axis should be 120° SSE
  5. Ideally I'd like to have my origin at bottom-left (math plot style, not computer graphics). But Bokeh apparently doesn't support that, I can live without that. However defaulting the y-axis labels to negative, while requiring a mix of positive and negative coords, is confusing. Anyway, how to hack an automatic fix to that with minimum grief? (manual p.yrange = Range1d(?, ?)?)
  6. Bokeh's approach to attaching (hex) glyphs to plots is a hard idiom to use. Ideally I simply want to reference (q,r)-coords everywhere for hexes, labels, axes. I never want to see (x,y)-coords appearing on axes, label coords, tick-marks, etc. but seems Bokeh won't allow you. I guess you have to manually hack the axes and ticks later. Also, the plot<->glyph interface doesn't allow you to expose a (q,r) <-> (x,y) coord transform function, certainly not a bidirectional one.
  7. The default axes don't seem to have any accessors to automatically find their current extent/limits; p.yaxis.start/end are empty unless you specified them. The result from p.yaxis.major_tick_in,p.yaxis.major_tick_out is also wrong, for this plot it gives (2,6) for both x and y, seems to be clipping those to the interior multiples of 2(?). How to automatically get the axes' extent?

My current plot:

Bad HexTile plot

My code:

import pandas as pd
import numpy as np
from math import sqrt    
from bokeh.plotting import figure
from bokeh.models import ColumnDataSource
from bokeh.models.glyphs import HexTile
from bokeh.io import show

# Data source is a list of county abbreviations, in (q,r) coords...
counties = np.array([
    ['TE','DY','AM','DN', ''],
    ['DL','FM','MN','AH', ''],
    ['SO','LM','CN','LH', ''],
    ['MO','RN','LD','WH','MH'],
    ['GA','OY','KE','D',  ''],
    ['',  'CE','LS','WW', ''],
    ['LC','TA','KK','CW', ''],
    ['KY','CR','WF','WX', ''],
    ])
#counties = counties[::-1] # UNUSED: flip so origin is at bottom-left

# (q,r) Coordinate system is “odd/even-r” horizontal Offset coords
r, q = np.mgrid[0:8, (4+1):0:-1]
q = q[counties!='']
r = r[counties!='']

sqrt3 = sqrt(3)
# Try to transform odd-r (q,r) offset coords -> (x,y). Per Red Blob Games' tutorial.
x = q - (r//2) # this may be slightly dubious
y = r

counties_df = pd.DataFrame({'q': q, 'r': r, 'abbrev': counties[counties!=''], 'x': x, 'y': y })
counties_ds = ColumnDataSource(ColumnDataSource.from_df(counties_df)) # ({'q': q, 'r': r, 'abbrev': counties[counties != '']})

p = figure(tools='save,crosshair') # match_aspect=True?

glyph = HexTile(orientation='pointytop', q='x', r='y', size=0.76, fill_color='#f6f699', line_color='black') # q,r,size,scale=??!?!!? size=0.76 is an empirical hack.
p.add_glyph(counties_ds, glyph)

p.xaxis.minor_tick_line_color = None
p.yaxis.minor_tick_line_color = None

print(f'Axes: x={p.xaxis.major_tick_in}:{p.xaxis.major_tick_out} y={p.yaxis.major_tick_in}:{p.yaxis.major_tick_out}')

# Now can't manage to get the right coords for text labels
p.text(q, -r, text=["(%d, %d)" % (q,r) for (q, r) in zip(q, r)], text_baseline="middle", text_align="center")
# Ideally I ultimately want to fix this and plot `abbrev` column as the text label

show(p)

Solution

  • Below are my solution and plot. Mainly per @bigreddot's advice, but there's still some coordinate hacking needed:

    1. Expecting users to pass input coords as axial instead of offset coords is a major limitation. I work around this. There's no point in creating a offset_to_cartesian() because we need to negate r in two out of three places:
    2. My input is even-r offset coords. I still need to manually apply the offset: q = q + (r+1)//2
    3. I need to manually negate r in both the axial_to_cartesian() call and the datasource creation for the glyph. (But not in the text() call).
    4. The call needs to be: axial_to_cartesian(q, -r, size=2/3, orientation='pointytop')
    5. Need p = figure(match_aspect=True ...) to prevent squishing
    6. I need to manually create my x,y axes to get the range right

    Solution:

    import pandas as pd
    import numpy as np
    from math import sqrt
    from bokeh.plotting import figure
    from bokeh.models import ColumnDataSource, Range1d
    from bokeh.models.glyphs import HexTile
    from bokeh.io import curdoc, show
    from bokeh.util.hex import cartesian_to_axial, axial_to_cartesian
    
    counties = np.array([
        ['DL','DY','AM','',    ''],
        ['FM','TE','AH','DN',  ''],
        ['SO','LM','CN','MN',  ''],
        ['MO','RN','LD','MH','LH'],
        ['GA','OY','WH','D' ,''  ],
        [''  ,'CE','LS','KE','WW'],
        ['LC','TA','KK','CW',''  ],
        ['KY','CR','WF','WX',''  ]
        ])
    
    counties = np.flip(counties, (0)) # Flip UD for bokeh
    
    # (q,r) Coordinate system is “odd/even-r” horizontal Offset coords
    r, q = np.mgrid[0:8, 0:(4+1)]
    q = q[counties!='']
    r = r[counties!='']
    
    # Transform for odd-r offset coords; +r-axis goes up
    q = q + (r+1)//2
    #r = -r # cannot globally negate 'r', see comments
    
    # Transform odd-r offset coords (q,r) -> (x,y)
    x, y = axial_to_cartesian(q, -r, size=2/3, orientation='pointytop')
    
    counties_df = pd.DataFrame({'q': q, 'r': -r, 'abbrev': counties[counties!=''], 'x': x, 'y': y })
    counties_ds = ColumnDataSource(ColumnDataSource.from_df(counties_df)) # ({'q': q, 'r': r, 'abbrev': counties[counties != '']})
    
    p = figure(match_aspect=True, tools='save,crosshair')
    
    glyph = HexTile(orientation='pointytop', q='q', r='r', size=2/3, fill_color='#f6f699', line_color='black') # q,r,size,scale=??!?!!?
    p.add_glyph(counties_ds, glyph)
    
    p.x_range = Range1d(-2,6)
    p.y_range = Range1d(-1,8)
    p.xaxis.minor_tick_line_color = None
    p.yaxis.minor_tick_line_color = None
    
    p.text(x, y, text=["(%d, %d)" % (q,r) for (q, r) in zip(q, r)],
        text_baseline="middle", text_align="center")
    
    show(p)
    

    Corrected plot