from __future__ import annotations
import base64
import copy
from datetime import datetime, date, time
from decimal import Decimal
import json
import os
import re
import msgpack
from typing import Any, Dict, Literal, TypeAlias, TypedDict, Union
import requests
from lightningchart.themes import CSS_COLOR_NAMES, Color
try:
import numpy as np
_HAS_NUMPY = True
except ImportError:
np = None
_HAS_NUMPY = False
try:
import pandas as pd
except ImportError:
pd = None
[docs]
def convert_to_list(arg):
"""Converts various data types to a Python list format.
For v8 compatibility, handles Color objects and preserves single values
when appropriate to avoid asymmetric data issues.
Args:
arg: The input object to be converted to a list.
Returns:
A Python list containing the converted values from the input,
or the original value if it should remain as a single value.
"""
try:
if arg is None:
return None
elif isinstance(arg, list):
return arg
elif isinstance(arg, (int, float, str)):
return [arg]
elif isinstance(arg, (tuple, set)):
return list(arg)
elif isinstance(arg, dict):
return list(arg.values())
elif np and isinstance(arg, np.ndarray):
return arg.tolist()
elif pd and isinstance(arg, (pd.Series, pd.Index)):
return arg.tolist()
elif hasattr(arg, '__class__') and 'Color' in str(type(arg)):
return arg
elif hasattr(arg, '__iter__') and not isinstance(arg, (str, bytes)):
return list(arg)
else:
return [arg]
except TypeError:
return arg
[docs]
def convert_to_dict(arg):
"""Converts various data types to a Python dictionary format.
Args:
arg: The input object to be converted to a dictionary.
Returns:
A Python dictionary containing the converted values from the input.
"""
try:
if arg is None:
return None
elif isinstance(arg, list):
return [convert_to_dict(item) for item in arg]
elif isinstance(arg, dict):
return arg
elif pd and isinstance(arg, pd.DataFrame):
return arg.to_dict(orient='records')
elif pd and isinstance(arg, pd.Series):
return arg.to_dict()
return dict(arg)
except TypeError:
raise TypeError(f'Data type {type(arg)} is not supported.')
[docs]
def convert_to_matrix(arg):
"""Converts various multidimensional data types to a Python matrix represented as
a list of lists containing native Python numbers (int or float).
Args:
arg: The input object to be converted to a matrix.
Returns:
A Python list of lists representing the converted matrix.
"""
try:
if arg is None:
return None
elif isinstance(arg, list) and all(isinstance(row, list) for row in arg):
return arg
elif np and isinstance(arg, np.ndarray):
return arg.tolist()
elif pd and isinstance(arg, pd.DataFrame):
return arg.values.tolist()
elif isinstance(arg, tuple) and all(isinstance(row, (tuple, list)) for row in arg):
return [list(row) for row in arg]
elif hasattr(arg, '__iter__'):
return [convert_to_matrix(item) for item in arg]
return [arg]
except TypeError:
raise TypeError(f'Data type {type(arg)} is not supported.')
[docs]
def convert_to_unix_time(arg, str_format: str = None):
"""Convert various datetime formats to Unix timestamp in milliseconds.
Args:
arg: The datetime value(s) to convert. Acceptable types include:
int, float, datetime, pd.Timestamp, np.datetime64, str, or list
str_format: The expected format of the string date if `arg` is a string and not in ISO format.
This should follow the Python `datetime.strptime` format codes.
Returns:
A Unix timestamp in milliseconds as an integer if a single item was passed, or a list of
Unix timestamps in milliseconds if a list was passed.
"""
try:
if isinstance(arg, list):
return [convert_to_unix_time(item, str_format) for item in arg]
if isinstance(arg, bool):
raise TypeError('Boolean is not a valid timestamp type.')
if isinstance(arg, (int, float)):
return arg
elif isinstance(arg, datetime):
return int(arg.timestamp() * 1000)
elif pd and isinstance(arg, pd.Timestamp):
return int(arg.timestamp() * 1000)
elif np and isinstance(arg, np.datetime64):
return int(arg.astype('datetime64[ms]').astype('int64'))
elif isinstance(arg, str):
if str_format:
return int(datetime.strptime(arg, str_format).timestamp() * 1000)
else:
return int(datetime.fromisoformat(arg.replace('Z', '+00:00')).timestamp() * 1000)
else:
raise TypeError(f'Data type {type(arg)} is not supported for timestamp conversion.')
except ValueError:
raise ValueError('Input cannot be converted to a timestamp')
[docs]
def convert_to_base64(source: str) -> str:
"""
Converts an image or video file (local or remote) to a Base64 data URI.
Args:
source (str): File path or URL. If the source already starts with 'data:', it is returned unchanged.
Returns:
str: A data URI in the form 'data:<mime_type>;base64,<base64_data>'.
Raises:
FileNotFoundError: If a local file is not found.
ValueError: If the file extension is unsupported.
"""
if source.startswith('data:'):
return source
if source.startswith('http://') or source.startswith('https://'):
response = requests.get(source, timeout=30)
response.raise_for_status()
data = response.content
mime_type = response.headers.get('Content-Type')
if not mime_type:
lower = source.lower()
if lower.endswith('.mp4'):
mime_type = 'video/mp4'
elif lower.endswith('.webm'):
mime_type = 'video/webm'
elif lower.endswith('.gif'):
mime_type = 'image/gif'
elif lower.endswith('.jpg') or lower.endswith('.jpeg'):
mime_type = 'image/jpeg'
elif lower.endswith('.png'):
mime_type = 'image/png'
else:
raise ValueError('Unsupported file extension for URL: ' + source)
return f'data:{mime_type};base64,{base64.b64encode(data).decode("utf-8")}'
if not os.path.exists(source):
raise FileNotFoundError(f'File not found: {source}')
with open(source, 'rb') as f:
data = f.read()
lower = source.lower()
if lower.endswith('.mp4'):
mime_type = 'video/mp4'
elif lower.endswith('.webm'):
mime_type = 'video/webm'
elif lower.endswith('.gif'):
mime_type = 'image/gif'
elif lower.endswith('.jpg') or lower.endswith('.jpeg'):
mime_type = 'image/jpeg'
elif lower.endswith('.png'):
mime_type = 'image/png'
else:
raise ValueError('Unsupported file extension: ' + source)
return f'data:{mime_type};base64,{base64.b64encode(data).decode("utf-8")}'
ColorInput: TypeAlias = (
str # Hex "#FF0000" or named "red"
| int # Packed RGB 0xFF0000 or ARGB 0xFFFF0000
| tuple[int, int, int] # RGB (255, 0, 0)
| tuple[int, int, int, int] # RGBA (255, 0, 0, 255)
| dict[str, int] # {'r': 255, 'g': 0, 'b': 0, 'a': 255}
| Color # lightningchart.Color object
)
[docs]
def convert_color_to_hex(color: ColorInput) -> str:
"""
Convert various color representations to a hex string.
Supports:
- Hex strings (3, 4, 6, or 8 characters)
- CSS color names
- Integer RGB (0x000000 to 0xFFFFFF) or ARGB (0x00000000 to 0xFFFFFFFF)
- RGB tuples/lists (3 or 4 integers in 0-255 range)
- RGB dicts (keys: 'r', 'g', 'b' and optional 'a')
- lightningchart.Color objects
Args:
color: Color representation to convert.
Returns:
Hex color string in the format '#RRGGBB' or '#RRGGBBAA'.
"""
HEX_COLOR_RE = re.compile(r'^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$')
# lightningchart.Color object
if isinstance(color, Color):
return color.get_hex()
if isinstance(color, str):
lower = color.strip().lower()
if lower in ('transparent', 'none'):
return 'transparent'
match = HEX_COLOR_RE.match(lower)
if match:
hex_value = match.group(1)
if len(hex_value) == 3:
hex_value = ''.join(c * 2 for c in hex_value)
elif len(hex_value) == 4:
hex_value = ''.join(c * 2 for c in hex_value)
return f'#{hex_value}'
if lower in CSS_COLOR_NAMES:
return CSS_COLOR_NAMES[lower]
raise ValueError(f'Invalid hex or CSS color string: {color}')
# Tuple or list input (RGB[A])
if isinstance(color, (tuple, list)):
if 3 <= len(color) <= 4 and all(isinstance(v, int) and 0 <= v <= 255 for v in color):
r, g, b = color[:3]
a = color[3] if len(color) == 4 else None
if a is not None:
return f'#{r:02X}{g:02X}{b:02X}{a:02X}'
return f'#{r:02X}{g:02X}{b:02X}'
raise ValueError('Tuple/list color must have 3 or 4 integers in 0-255 range.')
# Dict input (r, g, b[, a])
if isinstance(color, dict):
r, g, b = color.get('r'), color.get('g'), color.get('b')
a = color.get('a')
if r is None or g is None or b is None:
raise ValueError('Dict color must have r, g, b keys.')
if all(isinstance(v, int) and 0 <= v <= 255 for v in (r, g, b)):
if a is not None and isinstance(a, int) and 0 <= a <= 255:
return f'#{r:02X}{g:02X}{b:02X}{a:02X}'
return f'#{r:02X}{g:02X}{b:02X}'
raise ValueError('Dict color must have integer r, g, b (and optional a) in 0-255 range.')
# Integer input (RGB or ARGB)
if isinstance(color, int):
if not (0 <= color <= 0xFFFFFFFF):
raise ValueError('Integer color must be in range 0x000000 to 0xFFFFFFFF.')
if color <= 0xFFFFFF:
# RGB format: 0xRRGGBB
r = (color >> 16) & 0xFF
g = (color >> 8) & 0xFF
b = color & 0xFF
return f'#{r:02X}{g:02X}{b:02X}'
else:
# ARGB format: 0xAARRGGBB → convert to #RRGGBBAA
a = (color >> 24) & 0xFF
r = (color >> 16) & 0xFF
g = (color >> 8) & 0xFF
b = color & 0xFF
return f'#{r:02X}{g:02X}{b:02X}{a:02X}'
# Object with r, g, b, (optional a) attributes
if hasattr(color, 'r') and hasattr(color, 'g') and hasattr(color, 'b'):
r = getattr(color, 'r')
g = getattr(color, 'g')
b = getattr(color, 'b')
a = getattr(color, 'a', None)
if all(isinstance(v, int) and 0 <= v <= 255 for v in (r, g, b)):
if a is not None and isinstance(a, int) and 0 <= a <= 255:
return f'#{r:02X}{g:02X}{b:02X}{a:02X}'
return f'#{r:02X}{g:02X}{b:02X}'
raise ValueError(
f'Invalid color input: {color}. Pass a hex string, CSS color name, RGB/ARGB integer, 3-4 tuple, dict with r/g/b keys, or Color object.'
)
[docs]
def build_legend_config(legend=None):
"""Build legend configuration from legend dictionary."""
if legend is None:
return {'visible': True}
legend_config = {}
legend_config['visible'] = legend.get('visible', True)
if 'position' in legend:
position = legend['position']
if isinstance(position, str):
legend_config['position'] = position
elif hasattr(position, 'value'):
legend_config['position'] = position.value
else:
legend_config['position'] = position
simple_mappings = {
'title': 'title',
'title_font': 'titleFont',
'title_fill_style': 'titleFillStyle',
'orientation': 'orientation',
'margin_inner': 'marginInner',
'entry_margin': 'entryMargin',
'auto_hide_threshold': 'autoHideThreshold',
'padding': 'padding',
'margin_outer': 'marginOuter',
}
for python_key, js_key in simple_mappings.items():
if python_key in legend:
legend_config[js_key] = legend[python_key]
bool_mappings = {
'render_on_top': 'renderOnTop',
'background_visible': 'backgroundVisible',
'add_entries_automatically': 'addEntriesAutomatically',
}
for python_key, js_key in bool_mappings.items():
if python_key in legend:
legend_config[js_key] = legend[python_key]
if 'background_fill_style' in legend:
legend_config['backgroundFillStyle'] = legend['background_fill_style']
if 'background_stroke_style' in legend:
legend_config['backgroundStrokeStyle'] = legend['background_stroke_style']
if 'entries' in legend:
entries = legend['entries']
entries_config = {}
entries_mappings = {
'show': 'show',
'text': 'text',
'button_shape': 'buttonShape',
'button_size': 'buttonSize',
'button_fill_style': 'buttonFillStyle',
'button_stroke_style': 'buttonStrokeStyle',
'button_rotation': 'buttonRotation',
'text_font': 'textFont',
'text_fill_style': 'textFillStyle',
'match_style_exactly': 'matchStyleExactly',
'highlight': 'highlight',
'lut': 'lut',
'lut_length': 'lutLength',
'lut_thickness': 'lutThickness',
'lut_display_proportional_steps': 'lutDisplayProportionalSteps',
}
for python_key, js_key in entries_mappings.items():
if python_key in entries:
entries_config[js_key] = entries[python_key]
if entries_config:
legend_config['entries'] = entries_config
return legend_config
[docs]
def apply_post_legend_config(chart_instance, legend_config):
"""
Apply post-initialization legend configurations that require instance methods.
Args:
chart_instance: Chart instance with legend property
legend_config (dict): Legend configuration dictionary
"""
if not legend_config or not hasattr(chart_instance, 'legend'):
return
options_to_apply = {}
color_mappings = {'title_fill_style': 'title_fill_style', 'background_fill_style': 'background_fill_style'}
for config_key, option_key in color_mappings.items():
if config_key in legend_config:
options_to_apply[option_key] = legend_config[config_key]
if 'background_stroke_style' in legend_config:
options_to_apply['background_stroke_style'] = legend_config['background_stroke_style']
if 'orientation' in legend_config:
options_to_apply['orientation'] = legend_config['orientation']
if 'entries' in legend_config:
entries = legend_config['entries']
entries_options = {}
if 'button_shape' in entries:
entries_options['button_shape'] = entries['button_shape']
entry_color_mappings = {'button_fill_style': 'button_fill_style', 'text_fill_style': 'text_fill_style'}
for config_key, option_key in entry_color_mappings.items():
if config_key in entries:
entries_options[option_key] = entries[config_key]
if 'button_stroke_style' in entries:
entries_options['button_stroke_style'] = entries['button_stroke_style']
if entries_options:
options_to_apply['entries'] = entries_options
if options_to_apply and hasattr(chart_instance.legend, 'set_options'):
chart_instance.legend.set_options(**options_to_apply)
[docs]
def build_series_legend_options(legend=None):
"""
Build series legend options from dictionary.
Args:
legend (dict): Legend configuration dictionary
Returns:
dict: Processed legend options for series, or None if no options
"""
if not legend:
return None
legend_options = {}
legend_params = {
'show': 'show',
'text': 'text',
'button_shape': 'buttonShape',
'button_size': 'buttonSize',
'button_fill_style': 'buttonFillStyle',
'button_stroke_style': 'buttonStrokeStyle',
'button_rotation': 'buttonRotation',
'text_font': 'textFont',
'text_fill_style': 'textFillStyle',
'match_style_exactly': 'matchStyleExactly',
'highlight': 'highlight',
'lut': 'lut',
'lut_length': 'lutLength',
'lut_thickness': 'lutThickness',
'lut_display_proportional_steps': 'lutDisplayProportionalSteps',
'events': 'events',
}
color_params = {'button_fill_style', 'text_fill_style'}
for kwarg_key, option_key in legend_params.items():
value = legend.get(kwarg_key)
if value is not None:
if kwarg_key in color_params:
from lightningchart.utils import convert_color_to_hex
legend_options[option_key] = convert_color_to_hex(value)
else:
legend_options[option_key] = value
return legend_options if legend_options else None
_JS_TYPEDARRAY_MAP = {
0x11: ('int8', 'int8'),
0x12: ('uint8', 'uint8'),
0x13: ('int16', 'int16'),
0x14: ('uint16', 'uint16'),
0x15: ('int32', 'int32'),
0x16: ('uint32', 'uint32'),
0x17: ('float32', 'float32'),
0x18: ('float64', 'float64'),
0x19: ('uint8', 'uint8'),
}
CSS_NAMED_COLORS = {
'black': (0, 0, 0),
'white': (255, 255, 255),
'red': (255, 0, 0),
'lime': (0, 255, 0),
'blue': (0, 0, 255),
'fuchsia': (255, 0, 255),
'magenta': (255, 0, 255),
'yellow': (255, 255, 0),
'cyan': (0, 255, 255),
'aqua': (0, 255, 255),
}
def _ext_to_array(ext: msgpack.ExtType, as_numpy: bool):
code, data = ext.code, ext.data
if code not in _JS_TYPEDARRAY_MAP:
return ext
_, np_dtype = _JS_TYPEDARRAY_MAP[code]
if _HAS_NUMPY and as_numpy:
return np.frombuffer(data, dtype=np_dtype).copy()
import array as _array
_pycode = {'int8': 'b', 'uint8': 'B', 'int16': 'h', 'uint16': 'H', 'int32': 'i', 'uint32': 'I', 'float32': 'f', 'float64': 'd'}[np_dtype]
arr = _array.array(_pycode)
arr.frombytes(data)
return arr.tolist()
CSS_NAME_BY_RGB = {rgb: name for name, rgb in CSS_NAMED_COLORS.items()}
def _postprocess_readback(
d,
*,
colors: Literal['uint32', 'hex', 'hex_rgba', 'rgb', 'html'] | None = None,
):
"""
colors:
- "uint32": keep/convert to np.uint32 (or list of ints)
- "hex": add 'colorsHex' as '#RRGGBB'
- "hex_rgba": add 'colorsHex' as '#RRGGBBAA'
- "rgb": add 'colorsRGB' as 'rgb(r, g, b)' or 'rgba(r, g, b, a)'
- "html": add 'colorsHTML' using exact CSS name if known (alpha==255), else hex/rgb fallback
"""
cols = d.get('colors')
if cols is None:
return d
try:
import numpy as np
u = np.asarray(cols).astype(np.uint32, copy=False)
r = (u & 0xFF).astype(np.uint32, copy=False)
g = ((u >> 8) & 0xFF).astype(np.uint32, copy=False)
b = ((u >> 16) & 0xFF).astype(np.uint32, copy=False)
a = ((u >> 24) & 0xFF).astype(np.uint32, copy=False)
if colors == 'uint32':
d['colors'] = u
if colors in ('hex', 'hex_rgba'):
if colors == 'hex_rgba':
d['colorsHex'] = [f'#{int(rr):02x}{int(gg):02x}{int(bb):02x}{int(aa):02x}' for rr, gg, bb, aa in zip(r, g, b, a)]
else:
d['colorsHex'] = [f'#{int(rr):02x}{int(gg):02x}{int(bb):02x}' for rr, gg, bb in zip(r, g, b)]
if colors == 'rgb':
def fmt_rgb(rr, gg, bb, aa):
return f'rgb({rr}, {gg}, {bb})' if aa == 255 else f'rgba({rr}, {gg}, {bb}, {aa / 255:.3f})'
d['colorsRGB'] = [fmt_rgb(int(rr), int(gg), int(bb), int(aa)) for rr, gg, bb, aa in zip(r, g, b, a)]
if colors == 'html':
out = []
for rr, gg, bb, aa in zip(r, g, b, a):
if aa == 255:
name = CSS_NAME_BY_RGB.get((int(rr), int(gg), int(bb)))
if name:
out.append(name)
continue
out.append(f'#{int(rr):02x}{int(gg):02x}{int(bb):02x}')
else:
out.append(f'rgba({int(rr)}, {int(gg)}, {int(bb)}, {int(aa) / 255:.3f})')
d['colorsHTML'] = out
except Exception:
vals = [int(v) & 0xFFFFFFFF for v in cols]
def split(u):
return (u & 0xFF, (u >> 8) & 0xFF, (u >> 16) & 0xFF, (u >> 24) & 0xFF)
if colors == 'uint32':
d['colors'] = vals
if colors in ('hex', 'hex_rgba'):
if colors == 'hex_rgba':
d['colorsHex'] = [f'#{r:02x}{g:02x}{b:02x}{a:02x}' for r, g, b, a in map(split, vals)]
else:
d['colorsHex'] = [f'#{r:02x}{g:02x}{b:02x}' for r, g, b, _ in map(split, vals)]
if colors == 'rgb':
def fmt_rgb(r, g, b, a):
return f'rgb({r}, {g}, {b})' if a == 255 else f'rgba({r}, {g}, {b}, {a / 255:.3f})'
d['colorsRGB'] = [fmt_rgb(*split(u)) for u in vals]
if colors == 'html':
out = []
for u in vals:
r, g, b, a = split(u)
if a == 255:
name = CSS_NAME_BY_RGB.get((r, g, b))
out.append(name if name else f'#{r:02x}{g:02x}{b:02x}')
else:
out.append(f'rgba({r}, {g}, {b}, {a / 255:.3f})')
d['colorsHTML'] = out
return d
def _walk_decode(obj: Any, as_numpy: bool):
if isinstance(obj, msgpack.ExtType):
return _ext_to_array(obj, as_numpy)
if isinstance(obj, dict):
return {k: _walk_decode(v, as_numpy) for k, v in obj.items()}
if isinstance(obj, (list, tuple)):
return [_walk_decode(v, as_numpy) for v in obj]
return obj
[docs]
class LegendEntryOptions(TypedDict, total=False):
"""Configuration for legend.set_entry_options() method."""
show: bool
text: str
button_shape: str
button_size: Union[int, Dict[str, int]]
button_fill_style: str
button_stroke_style: Dict[str, Union[int, str]]
button_rotation: float
text_font: Dict[str, Union[int, str]]
text_fill_style: str
match_style_exactly: bool
highlight: bool
lut: Any
lut_length: int
lut_thickness: int
lut_display_proportional_steps: bool
[docs]
class LegendOptions(TypedDict, total=False):
"""Configuration dictionary for chart legends."""
visible: bool
position: Union[str, Dict[str, Union[int, str]]]
title: str
title_font: Dict[str, Union[int, str]]
title_fill_style: str
orientation: str
render_on_top: bool
background_visible: bool
background_fill_style: str
background_stroke_style: Dict[str, Union[int, str]]
padding: Union[int, Dict[str, int]]
margin_inner: int
margin_outer: Union[int, Dict[str, int]]
entry_margin: int
auto_hide_threshold: float
add_entries_automatically: bool
entries: LegendEntryOptions
[docs]
def convert_for_serialization(obj):
"""Convert numpy/pandas types to JSON/msgpack-serializable types."""
if np and isinstance(obj, (np.integer, np.floating)):
return obj.item()
elif np and isinstance(obj, np.ndarray):
return obj.tolist()
elif np and isinstance(obj, np.bool_):
return bool(obj)
elif isinstance(obj, Decimal):
return float(obj)
elif pd and isinstance(obj, pd.Timestamp):
return int(obj.timestamp() * 1000)
elif isinstance(obj, datetime):
return int(obj.timestamp() * 1000)
elif np and isinstance(obj, np.datetime64):
return int(obj.astype('datetime64[ms]').astype('int64'))
elif isinstance(obj, date):
return int(datetime.combine(obj, time()).timestamp() * 1000)
elif hasattr(obj, '__class__') and 'Color' in str(type(obj)):
from lightningchart.utils import convert_color_to_hex
return convert_color_to_hex(obj)
return None
[docs]
class NumpyEncoder(json.JSONEncoder):
[docs]
def default(self, obj):
result = convert_for_serialization(obj)
if result is not None:
return result
return super().default(obj)
[docs]
def msgpack_default(obj):
result = convert_for_serialization(obj)
if result is not None:
return result
return obj
[docs]
def process_spark_chart_color(color):
"""Convert color to hex if string, preserve other formats."""
if isinstance(color, str):
try:
return convert_color_to_hex(color)
except ValueError:
return color
return color
[docs]
def process_spark_chart_marker(marker):
"""Process marker colors."""
m = marker.copy()
if 'fillStyle' in m:
m['fillStyle'] = process_spark_chart_color(m['fillStyle'])
if 'strokeStyle' in m and isinstance(m['strokeStyle'], str):
m['strokeStyle'] = process_spark_chart_color(m['strokeStyle'])
return m
[docs]
def process_spark_chart_cell(cell):
"""Process spark chart cell with color conversions."""
if not (isinstance(cell, dict) and 'type' in cell and 'data' in cell):
return cell
processed = copy.deepcopy(cell)
chart_type = processed['type']
data = processed['data']
if chart_type in ('spark-line', 'spark-area') and isinstance(data, list):
if data and not isinstance(data[0], dict):
processed['data'] = [{'x': i, 'y': v} for i, v in enumerate(data)]
if 'fillStyle' in processed:
processed['fillStyle'] = process_spark_chart_color(processed['fillStyle'])
if 'strokeStyle' in processed:
if isinstance(processed['strokeStyle'], str):
processed['strokeStyle'] = process_spark_chart_color(processed['strokeStyle'])
elif isinstance(processed['strokeStyle'], dict) and 'color' in processed['strokeStyle']:
processed['strokeStyle']['color'] = process_spark_chart_color(processed['strokeStyle']['color'])
if 'winFillStyle' in processed:
processed['winFillStyle'] = process_spark_chart_color(processed['winFillStyle'])
if 'lossFillStyle' in processed:
processed['lossFillStyle'] = process_spark_chart_color(processed['lossFillStyle'])
if 'markers' in processed and isinstance(processed['markers'], list):
processed['markers'] = [process_spark_chart_marker(m) for m in processed['markers']]
return processed
[docs]
class PaddingKwargs(TypedDict, total=False):
left: int | float
right: int | float
top: int | float
bottom: int | float
[docs]
def normalize_schema(schema: dict | None) -> dict | None:
"""Normalize user-provided schema for series (validates storage types and copies known keys).
Returns processed schema dict or None if input is falsy.
"""
if not schema:
return None
storage_map = {
'Int8Array': 'Int8Array',
'Uint8Array': 'Uint8Array',
'Int16Array': 'Int16Array',
'Uint16Array': 'Uint16Array',
'Int32Array': 'Int32Array',
'Uint32Array': 'Uint32Array',
'Uint8ClampedArray': 'Uint8ClampedArray',
'Float32Array': 'Float32Array',
'Float64Array': 'Float64Array',
}
processed_schema: dict = {}
for key, config in schema.items():
if not isinstance(config, dict):
raise ValueError(f"Schema for '{key}' must be a dict.")
processed_config: dict = {}
if 'auto' in config:
processed_config['auto'] = config['auto']
if 'pattern' in config:
processed_config['pattern'] = config['pattern']
if 'storage' in config:
if config['storage'] in storage_map:
processed_config['storage'] = config['storage']
else:
raise ValueError(f'Invalid storage type: {config["storage"]}')
if 'ensureNoDuplication' in config:
processed_config['ensureNoDuplication'] = config['ensureNoDuplication']
processed_schema[key] = processed_config
return processed_schema
[docs]
def compose_tick_args(
text_color=None,
text_rotation: float | None = None,
font_size: float | None = None,
font_family: str | None = None,
font_weight: str | None = None,
font_style: str | None = None,
text_padding: float | None = None,
tick_length: float | None = None,
tick_stroke_thickness: float | None = None,
tick_stroke_color=None,
background_fill_color=None,
background_stroke_thickness: float | None = None,
background_stroke_color=None,
padding: int | float | dict | None = None,
) -> dict:
args: dict = {}
if text_color is not None:
args['textColor'] = convert_color_to_hex(text_color) if text_color != 'transparent' else 'transparent'
if text_rotation is not None:
args['textRotation'] = text_rotation
if font_size is not None:
args['fontSize'] = font_size
if font_family is not None:
args['fontFamily'] = font_family
if font_weight is not None:
args['fontWeight'] = font_weight
if font_style is not None:
args['fontStyle'] = font_style
if text_padding is not None:
args['textPadding'] = text_padding
if tick_length is not None:
args['tickLength'] = tick_length
if tick_stroke_thickness is not None:
args['tickStrokeThickness'] = tick_stroke_thickness
if tick_stroke_color is not None:
args['tickStrokeColor'] = convert_color_to_hex(tick_stroke_color) if tick_stroke_color != 'transparent' else 'transparent'
if background_fill_color is not None:
args['backgroundFillColor'] = convert_color_to_hex(background_fill_color) if background_fill_color != 'transparent' else 'transparent'
if background_stroke_thickness is not None:
args['backgroundStrokeThickness'] = background_stroke_thickness
if background_stroke_color is not None:
args['backgroundStrokeColor'] = convert_color_to_hex(background_stroke_color) if background_stroke_color != 'transparent' else 'transparent'
if padding is not None:
args['padding'] = padding
return args
[docs]
def send_cursor_tick_style(cursor, command: str, **kwargs):
"""Internal helper: build tick-style args and send them to TS command handler."""
args = compose_tick_args(**kwargs)
cursor.instance.send(cursor.chart.id, command, args)
[docs]
class TickStyleKwargs(TypedDict, total=False):
text_color: object
text_rotation: float
font_size: float
font_family: str
font_weight: str
font_style: str
text_padding: float
tick_length: float
tick_stroke_thickness: float
tick_stroke_color: object
background_fill_color: object
background_stroke_thickness: float
background_stroke_color: object
padding: int | float | dict