Skip to content

Commit a4f7dbc

Browse files
authored
Merge pull request #16 from kdorr/axis-numeric
Axis numeric
2 parents 7a6f383 + 94d31ed commit a4f7dbc

File tree

47 files changed

+670
-12
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+670
-12
lines changed

mplaltair/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import matplotlib
22
import altair
3-
43
from ._convert import _convert
54

5+
66
def convert(chart):
77
"""Convert an altair encoding to a Matplotlib figure
88

mplaltair/_axis.py

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
import matplotlib.dates as mdates
2+
import matplotlib.ticker as ticker
3+
import numpy as np
4+
from ._data import _locate_channel_data, _locate_channel_dtype, _locate_channel_scale, _locate_channel_axis, _convert_to_mpl_date
5+
6+
7+
def _set_limits(channel, scale):
8+
"""Set the axis limits on the Matplotlib axis
9+
10+
Parameters
11+
----------
12+
channel : dict
13+
The mapping of the channel data and metadata
14+
scale : dict
15+
The mapping of the scale metadata and the scale data
16+
"""
17+
18+
_axis_kwargs = {
19+
'x': {'min': 'left', 'max': 'right'},
20+
'y': {'min': 'bottom', 'max': 'top'},
21+
}
22+
23+
lims = {}
24+
25+
if channel['dtype'] == 'quantitative':
26+
# determine limits
27+
if 'domain' in scale: # domain takes precedence over zero in Altair
28+
if scale['domain'] == 'unaggregated':
29+
raise NotImplementedError
30+
else:
31+
lims[_axis_kwargs[channel['axis']].get('min')] = scale['domain'][0]
32+
lims[_axis_kwargs[channel['axis']].get('max')] = scale['domain'][1]
33+
elif 'type' in scale and scale['type'] != 'linear':
34+
lims = _set_scale_type(channel, scale)
35+
else:
36+
# Check that a positive minimum is zero if zero is True:
37+
if ('zero' not in scale or scale['zero'] == True) and min(channel['data']) > 0:
38+
lims[_axis_kwargs[channel['axis']].get('min')] = 0 # quantitative sets min to be 0 by default
39+
40+
# Check that a negative maximum is zero if zero is True:
41+
if ('zero' not in scale or scale['zero'] == True) and max(channel['data']) < 0:
42+
lims[_axis_kwargs[channel['axis']].get('max')] = 0
43+
44+
elif channel['dtype'] == 'temporal':
45+
# determine limits
46+
if 'domain' in scale:
47+
domain = _convert_to_mpl_date(scale['domain'])
48+
lims[_axis_kwargs[channel['axis']].get('min')] = domain[0]
49+
lims[_axis_kwargs[channel['axis']].get('max')] = domain[1]
50+
elif 'type' in scale and scale['type'] != 'time':
51+
lims = _set_scale_type(channel, scale)
52+
53+
else:
54+
raise NotImplementedError # Ordinal and Nominal go here?
55+
56+
# set the limits
57+
if channel['axis'] == 'x':
58+
channel['ax'].set_xlim(**lims)
59+
else:
60+
channel['ax'].set_ylim(**lims)
61+
62+
63+
def _set_scale_type(channel, scale):
64+
"""If the scale is non-linear, change the scale and return appropriate axis limits.
65+
The 'linear' and 'time' scale types are not included here because quantitative defaults to 'linear'
66+
and temporal defaults to 'time'. The 'utc' and 'sequential' scales are currently not supported.
67+
68+
Parameters
69+
----------
70+
channel : dict
71+
The mapping of the channel data and metadata
72+
scale : dict
73+
The mapping of the scale metadata and the scale data
74+
75+
Returns
76+
-------
77+
lims : dict
78+
The axis limit mapped to the appropriate axis parameter for scales that change axis limit behavior
79+
"""
80+
lims = {}
81+
if scale['type'] == 'log':
82+
83+
base = 10 # default base is 10 in altair
84+
if 'base' in scale:
85+
base = scale['base']
86+
87+
if channel['axis'] == 'x':
88+
channel['ax'].set_xscale('log', basex=base)
89+
# lower limit: round down to nearest major tick (using log base change rule)
90+
lims['left'] = base**np.floor(np.log10(channel['data'].min())/np.log10(base))
91+
else: # y-axis
92+
channel['ax'].set_yscale('log', basey=base)
93+
# lower limit: round down to nearest major tick (using log base change rule)
94+
lims['bottom'] = base**np.floor(np.log10(channel['data'].min())/np.log10(base))
95+
96+
elif scale['type'] == 'pow' or scale['type'] == 'sqrt':
97+
"""The 'sqrt' scale is just the 'pow' scale with exponent = 0.5.
98+
When Matplotlib gets a power scale, the following should work:
99+
100+
exponent = 2 # default exponent value for 'pow' scale
101+
if scale['type'] == 'sqrt':
102+
exponent = 0.5
103+
elif 'exponent' in scale:
104+
exponent = scale['exponent']
105+
106+
if channel['axis'] == 'x':
107+
channel['ax'].set_xscale('power_scale', exponent=exponent)
108+
else: # y-axis
109+
channel['ax'].set_yscale('power_scale', exponent=exponent)
110+
"""
111+
raise NotImplementedError
112+
113+
elif scale['type'] == 'utc':
114+
raise NotImplementedError
115+
elif scale['type'] == 'sequential':
116+
raise NotImplementedError("sequential scales used primarily for continuous colors")
117+
else:
118+
raise NotImplementedError
119+
return lims
120+
121+
122+
def _set_tick_locator(channel, axis):
123+
"""Set the tick locator if it needs to vary from the default locator
124+
125+
Parameters
126+
----------
127+
channel : dict
128+
The mapping of the channel data and metadata
129+
axis : dict
130+
The mapping of the axis metadata and the scale data
131+
"""
132+
current_axis = {'x': channel['ax'].xaxis, 'y': channel['ax'].yaxis}
133+
if 'values' in axis:
134+
if channel['dtype'] == 'temporal':
135+
current_axis[channel['axis']].set_major_locator(ticker.FixedLocator(_convert_to_mpl_date(axis.get('values'))))
136+
elif channel['dtype'] == 'quantitative':
137+
current_axis[channel['axis']].set_major_locator(ticker.FixedLocator(axis.get('values')))
138+
else:
139+
raise NotImplementedError
140+
elif 'tickCount' in axis:
141+
current_axis[channel['axis']].set_major_locator(
142+
ticker.MaxNLocator(steps=[2, 5, 10], nbins=axis.get('tickCount')+1, min_n_ticks=axis.get('tickCount'))
143+
)
144+
145+
146+
def _set_tick_formatter(channel, axis):
147+
"""Set the tick formatter.
148+
149+
150+
Parameters
151+
----------
152+
channel : dict
153+
The mapping of the channel data and metadata
154+
axis : dict
155+
The mapping of the axis metadata and the scale data
156+
157+
Notes
158+
-----
159+
For quantitative formatting, Matplotlib does not support some format strings that Altair supports.
160+
Matplotlib only supports format strings as used by str.format().
161+
162+
For formatting of temporal data, Matplotlib does not support some format strings that Altair supports (%L, %Q, %s).
163+
Matplotlib only supports datetime.strftime formatting for dates.
164+
"""
165+
current_axis = {'x': channel['ax'].xaxis, 'y': channel['ax'].yaxis}
166+
format_str = ''
167+
168+
if 'format' in axis:
169+
format_str = axis['format']
170+
171+
if channel['dtype'] == 'temporal':
172+
if not format_str:
173+
format_str = '%b %d, %Y'
174+
175+
current_axis[channel['axis']].set_major_formatter(mdates.DateFormatter(format_str)) # May fail silently
176+
177+
elif channel['dtype'] == 'quantitative':
178+
if format_str:
179+
current_axis[channel['axis']].set_major_formatter(ticker.StrMethodFormatter('{x:' + format_str + '}'))
180+
181+
# Verify that the format string is valid for Matplotlib and exit nicely if not.
182+
try:
183+
current_axis[channel['axis']].get_major_formatter().__call__(1)
184+
except ValueError:
185+
raise ValueError("Matplotlib only supports format strings as used by `str.format()`."
186+
"Some format strings that work in Altair may not work in Matplotlib."
187+
"Please use a different format string.")
188+
else:
189+
raise NotImplementedError # Nominal and Ordinal go here
190+
191+
192+
def _set_label_angle(channel, axis):
193+
"""Set the label angle. TODO: handle axis.labelAngle from Altair
194+
195+
Parameters
196+
----------
197+
channel : dict
198+
The mapping of the channel data and metadata
199+
axis : dict
200+
The mapping of the axis metadata and the scale data
201+
"""
202+
if channel['dtype'] == 'temporal' and channel['axis'] == 'x':
203+
for label in channel['ax'].get_xticklabels():
204+
# Rotate the labels on the x-axis so they don't run into each other.
205+
label.set_rotation(30)
206+
label.set_ha('right')
207+
208+
209+
def convert_axis(ax, chart):
210+
"""Convert elements of the altair chart to Matplotlib axis properties
211+
212+
Parameters
213+
----------
214+
ax
215+
The Matplotlib axis to be modified
216+
chart
217+
The Altair chart
218+
"""
219+
220+
for channel in chart.to_dict()['encoding']:
221+
if channel in ['x', 'y']:
222+
chart_info = {'ax': ax, 'axis': channel,
223+
'data': _locate_channel_data(chart, channel),
224+
'dtype': _locate_channel_dtype(chart, channel)}
225+
if chart_info['dtype'] == 'temporal':
226+
chart_info['data'] = _convert_to_mpl_date(chart_info['data'])
227+
228+
scale_info = _locate_channel_scale(chart, channel)
229+
axis_info = _locate_channel_axis(chart, channel)
230+
231+
_set_limits(chart_info, scale_info)
232+
_set_tick_locator(chart_info, axis_info)
233+
_set_tick_formatter(chart_info, axis_info)
234+
_set_label_angle(chart_info, axis_info)

mplaltair/_convert.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import matplotlib.dates as mdates
2-
from ._data import _locate_channel_data, _locate_channel_dtype
2+
import numpy as np
3+
from ._data import _locate_channel_data, _locate_channel_dtype, _convert_to_mpl_date
34

45
def _allowed_ranged_marks(enc_channel, mark):
56
"""TODO: DOCS
@@ -120,10 +121,8 @@ def _convert(chart):
120121
data = _locate_channel_data(chart, channel)
121122
dtype = _locate_channel_dtype(chart, channel)
122123
if dtype == 'temporal':
123-
try:
124-
data = mdates.date2num(data) # Convert dates to Matplotlib dates
125-
except AttributeError:
126-
raise
124+
data = _convert_to_mpl_date(data)
125+
127126
mapping[_mappings[channel](dtype, data)[0]] = _mappings[channel](dtype, data)[1]
128127

129128
return mapping

mplaltair/_data.py

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
from ._exceptions import ValidationError
2+
import matplotlib.dates as mdates
3+
import matplotlib.cbook as cbook
4+
from datetime import datetime
5+
import numpy as np
26

37
def _locate_channel_dtype(chart, channel):
48
"""Locates dtype used for each channel
@@ -61,4 +65,120 @@ def _aggregate_channel():
6165

6266

6367
def _handle_timeUnit():
64-
raise NotImplementedError
68+
raise NotImplementedError
69+
70+
71+
def _locate_channel_scale(chart, channel):
72+
"""Locates the channel's scale information.
73+
74+
Parameters
75+
----------
76+
chart
77+
The Altair chart
78+
channel
79+
The Altair channel being examined
80+
81+
Returns
82+
-------
83+
A dictionary with the scale information
84+
"""
85+
channel_val = chart.to_dict()['encoding'][channel]
86+
if channel_val.get('scale'):
87+
return channel_val.get('scale')
88+
else:
89+
return {}
90+
91+
92+
def _locate_channel_axis(chart, channel):
93+
"""Locates the channel's scale information.
94+
95+
Parameters
96+
----------
97+
chart
98+
The Altair chart
99+
channel
100+
The Altair channel being examined
101+
102+
Returns
103+
-------
104+
A dictionary with the axis information
105+
"""
106+
channel_val = chart.to_dict()['encoding'][channel]
107+
if channel_val.get('axis'):
108+
return channel_val.get('axis')
109+
else:
110+
return {}
111+
112+
def _convert_to_mpl_date(data):
113+
"""Converts datetime, datetime64, strings, and Altair DateTime objects to Matplotlib dates.
114+
115+
Parameters
116+
----------
117+
data : datetime.datetime, numpy.datetime64, str, Altair DateTime, or sequences of any of these
118+
The data to be converted to a Matplotlib date
119+
120+
Returns
121+
-------
122+
new_data : list
123+
A list containing the converted date(s)
124+
"""
125+
126+
if cbook.iterable(data) and not isinstance(data, str) and not isinstance(data, dict):
127+
if len(data) == 0:
128+
return []
129+
else:
130+
return [_convert_to_mpl_date(i) for i in data]
131+
else:
132+
if isinstance(data, str): # string format for dates
133+
data = mdates.datestr2num(data)
134+
elif isinstance(data, np.datetime64): # sequence of datetimes, datetime64s
135+
data = mdates.date2num(data)
136+
elif isinstance(data, dict): # Altair DateTime
137+
data = mdates.date2num(_altair_DateTime_to_datetime(data))
138+
else:
139+
raise TypeError
140+
return data
141+
142+
143+
def _altair_DateTime_to_datetime(dt):
144+
"""Convert dictionary representation of an Altair DateTime to datetime object
145+
146+
Parameters
147+
----------
148+
dt : dict
149+
The dictionary representation of the Altair DateTime object to be converted.
150+
151+
Returns
152+
-------
153+
A datetime object
154+
"""
155+
MONTHS = {'Jan': 1, 'January': 1, 'Feb': 2, 'February': 2, 'Mar': 3, 'March': 3, 'Apr': 4, 'April': 4,
156+
'May': 5, 'May': 5, 'Jun': 6, 'June': 6, 'Jul': 7, 'July': 7, 'Aug': 8, 'August': 8,
157+
'Sep': 9, 'Sept': 9, 'September': 9, 'Oct': 10, 'October': 10, 'Nov': 11, 'November': 11,
158+
'Dec': 12, 'December': 12}
159+
160+
alt_to_datetime_kw_mapping = {'date': 'day', 'hours': 'hour', 'milliseconds': 'microsecond', 'minutes': 'minute',
161+
'month': 'month', 'seconds': 'second', 'year': 'year'}
162+
163+
datetime_kwargs = {'year': 0, 'month': 1, 'day': 1, 'hour': 0, 'minute': 0, 'second': 0, 'microsecond': 0}
164+
165+
if 'day' in dt or 'quarter' in dt:
166+
raise NotImplementedError
167+
if 'year' not in dt:
168+
raise KeyError('A year must be provided.')
169+
if 'month' not in dt:
170+
dt['month'] = 1 # Default to January
171+
else:
172+
if isinstance(dt['month'], str): # convert from str to number form for months
173+
dt['month'] = MONTHS[dt['month']]
174+
if 'date' not in dt:
175+
dt['date'] = 1 # Default to the first of the month
176+
if 'milliseconds' in dt:
177+
dt['milliseconds'] = dt['milliseconds']*1000 # convert to microseconds
178+
if 'utc' in dt:
179+
raise NotImplementedError("mpl-altair currently doesn't support timezones.")
180+
181+
for k, v in dt.items():
182+
datetime_kwargs[alt_to_datetime_kw_mapping[k]] = v
183+
184+
return datetime(**datetime_kwargs)
9.97 KB
11.5 KB
51.5 KB
53.6 KB
47.8 KB
10.8 KB

0 commit comments

Comments
 (0)