diff --git a/src/__init__.py b/src/__init__.py index ea0ea76..a76405f 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,4 +1,4 @@ # Import main API functions -from api.interface import plot, stop_server, get_server_status +from pycharting.api.interface import plot, stop_server, get_server_status __all__ = ["plot", "stop_server", "get_server_status", "__version__"] \ No newline at end of file diff --git a/src/pycharting/api/interface.py b/src/pycharting/api/interface.py index 3ccd1a0..6d86c74 100644 --- a/src/pycharting/api/interface.py +++ b/src/pycharting/api/interface.py @@ -18,6 +18,7 @@ import webbrowser import time import logging +import socket from typing import Optional, Dict, Any, Union import numpy as np import pandas as pd @@ -28,6 +29,38 @@ logger = logging.getLogger(__name__) + +def _get_local_ip() -> str: + """Get the local machine's IP address that can be reached externally. + + Uses a UDP socket trick - connects to an external address without sending data, + then reads which local IP was used. Falls back to hostname resolution or localhost. + """ + try: + # Create UDP socket (doesn't actually send anything) + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.settimeout(0.1) + # Connect to a public IP (Google DNS) - this doesn't send data, + # just determines which local interface would be used + s.connect(("8.8.8.8", 80)) + local_ip = s.getsockname()[0] + s.close() + return local_ip + except Exception: + pass + + # Fallback: try hostname resolution + try: + hostname = socket.gethostname() + local_ip = socket.gethostbyname(hostname) + if not local_ip.startswith("127."): + return local_ip + except Exception: + pass + + # Last resort + return "127.0.0.1" + # Global server instance _active_server: Optional[ChartServer] = None @@ -41,10 +74,12 @@ def plot( overlays: Optional[Dict[str, Union[np.ndarray, pd.Series, list]]] = None, subplots: Optional[Dict[str, Union[np.ndarray, pd.Series, list]]] = None, session_id: str = "default", + host: str = "127.0.0.1", port: Optional[int] = None, open_browser: bool = True, server_timeout: float = 2.0, block: bool = True, + external_host: Optional[str] = None, ) -> Dict[str, Any]: """ Generate and display an interactive OHLC (Open-High-Low-Close) or Line chart. @@ -66,16 +101,23 @@ def plot( low (Optional[Union[np.ndarray, pd.Series, list]]): Lowest prices during the interval. close (Optional[Union[np.ndarray, pd.Series, list]]): Closing prices. If only `close` is provided (without open/high/low), a line chart will be rendered instead of candlesticks. - overlays (Optional[Dict[str, Union[np.ndarray, pd.Series, list]]]): A dictionary of additional series to plot *over* the main price chart. - Keys are labels (e.g., "SMA 50"), values are data arrays. Useful for Moving Averages, Bollinger Bands, etc. + overlays (Optional[Dict[str, Union[np.ndarray, pd.Series, list, dict]]]): A dictionary of additional series to plot *over* the main price chart. + Keys are labels. Values can be: + - Simple: data array (renders as line) - e.g., `{"SMA 50": sma_array}` + - Styled: dict with `data`, `style`, `color`, `size` - e.g., `{"Markers": {"data": arr, "style": "marker", "color": "#00C853"}}` + Supported styles: "line" (default), "marker" (points), "dashed" (dashed line). subplots (Optional[Dict[str, Union[np.ndarray, pd.Series, list]]]): A dictionary of series to plot in separate panels *below* the main chart. Keys are labels (e.g., "RSI", "Volume"), values are data arrays. session_id (str): A unique identifier for this dataset. Use different IDs to keep multiple charts active simultaneously. Defaults to "default". + host (str): Host address to bind the server to. Use "0.0.0.0" to allow external connections (e.g., from Docker host). + Defaults to "127.0.0.1" (localhost only). port (Optional[int]): Specific port to run the server on. If `None` (default), a free port is automatically found. open_browser (bool): If `True` (default), automatically launches the system's default web browser to view the chart. server_timeout (float): Maximum time (in seconds) to wait for the server to start before proceeding. Defaults to 2.0. block (bool): If `True` (default), blocks execution until the browser page is closed. Useful in Jupyter notebooks. + external_host (Optional[str]): Override hostname/IP shown in URLs. When binding to "0.0.0.0", the local IP + is auto-detected. Use this parameter to override with a specific hostname or IP if needed. Returns: Dict[str, Any]: A dictionary containing execution details: @@ -160,9 +202,9 @@ def plot( if _active_server is None or not _active_server.is_running: logger.info("Starting ChartServer...") _active_server = ChartServer( - host="127.0.0.1", + host=host, port=port, - auto_shutdown_timeout=3.0 # 3 seconds after disconnect + auto_shutdown_timeout=60.0 # 60 seconds after disconnect (handles browser throttling) ) server_info = _active_server.start_server() @@ -177,7 +219,19 @@ def plot( # Construct chart URL with session ID and timestamp to bust cache # Use viewport demo which pulls data from the API for the given session ts = int(time.time()) - chart_url = f"{server_info['url']}/static/viewport-demo.html?session={session_id}&v={ts}" + + # Determine display host for URLs + # Priority: external_host > auto-detect (if 0.0.0.0) > server bind host + if external_host: + display_host = external_host + elif server_info['host'] == "0.0.0.0": + # Auto-detect local IP when binding to all interfaces + display_host = _get_local_ip() + else: + display_host = server_info['host'] + + display_url = f"http://{display_host}:{server_info['port']}" + chart_url = f"{display_url}/static/viewport-demo.html?session={session_id}&v={ts}" # Open browser if requested if open_browser: @@ -191,7 +245,7 @@ def plot( result = { "status": "success", "url": chart_url, - "server_url": server_info['url'], + "server_url": display_url, "session_id": session_id, "data_points": data_manager.length, "server_running": _active_server.is_running if _active_server else False, diff --git a/src/pycharting/api/routes.py b/src/pycharting/api/routes.py index bf41b70..a82c8d1 100644 --- a/src/pycharting/api/routes.py +++ b/src/pycharting/api/routes.py @@ -26,6 +26,14 @@ _data_managers: Dict[str, Any] = {} +class OverlayData(BaseModel): + """Overlay data with style configuration.""" + data: list + style: str = "line" # "line", "marker", "dashed" + color: Optional[str] = None + size: Optional[int] = None + + class DataResponse(BaseModel): """Response model for data endpoint.""" index: list @@ -33,8 +41,13 @@ class DataResponse(BaseModel): high: Optional[list] = None low: Optional[list] = None close: Optional[list] = None - overlays: Dict[str, list] = Field(default_factory=dict) - subplots: Dict[str, list] = Field(default_factory=dict) + # Overlays now include style metadata: {data, style, color, size} + overlays: Dict[str, OverlayData] = Field(default_factory=dict) + # Subplots can be: + # - Simple: {"RSI": [values...]} + # - Grouped: {"Stochastic": {"%K": [values...], "%D": [values...]}} + # - Histogram: {"Filter": {"_type": "histogram", "Buy": [values...], "Sell": [values...]}} + subplots: Dict[str, Any] = Field(default_factory=dict) start_index: int end_index: int total_length: int diff --git a/src/pycharting/data/ingestion.py b/src/pycharting/data/ingestion.py index f1edfc7..66731e7 100644 --- a/src/pycharting/data/ingestion.py +++ b/src/pycharting/data/ingestion.py @@ -10,10 +10,13 @@ The `DataManager` class is the core component here, acting as the optimized data store for a chart session. """ -from typing import Optional, Union, List, Dict, Any +from typing import Optional, Union, Dict, Any import pandas as pd import numpy as np +SubplotSeries = Union[pd.Series, np.ndarray, list] +SubplotsInput = Dict[str, Union[SubplotSeries, Dict[str, SubplotSeries]]] + class DataValidationError(Exception): """Exception raised when input data fails validation checks.""" @@ -27,7 +30,7 @@ def validate_input( low: Optional[Union[pd.Series, np.ndarray]] = None, close: Optional[Union[pd.Series, np.ndarray]] = None, overlays: Optional[Dict[str, Union[pd.Series, np.ndarray]]] = None, - subplots: Optional[Dict[str, Union[pd.Series, np.ndarray]]] = None, + subplots: Optional[SubplotsInput] = None, ) -> Dict[str, Any]: """ Validate and normalize input data for OHLC charting. @@ -159,16 +162,45 @@ def to_array(data: Optional[Union[pd.Series, np.ndarray, list]], name: str) -> O } # Validate and convert overlays + # Supports two formats: + # - {"SMA 20": array} # Simple line overlay + # - {"Markers": {"data": array, "style": "marker", ...}} # Styled overlay if overlays: for name, data in overlays.items(): - arr = to_array(data, f"Overlay '{name}'") - result["overlays"][name] = arr + if isinstance(data, dict) and "data" in data: + # Styled overlay format + arr = to_array(data["data"], f"Overlay '{name}'") + result["overlays"][name] = { + "data": arr, + "style": data.get("style", "line"), # "line", "marker", "dashed" + "color": data.get("color"), # Optional custom color + "size": data.get("size"), # Optional marker size + } + else: + # Simple format - just the array (backward compatible) + arr = to_array(data, f"Overlay '{name}'") + result["overlays"][name] = {"data": arr, "style": "line"} - # Validate and convert subplots + # Validate and convert subplots. + # Supports three formats: + # - {"RSI": array} # Simple line subplot + # - {"Stochastic": {"%K": array, "%D": array}} # Grouped line subplot + # - {"Filter": {"_type": "histogram", "Buy": array, "Sell": array}} # Histogram subplot if subplots: for name, data in subplots.items(): - arr = to_array(data, f"Subplot '{name}'") - result["subplots"][name] = arr + if isinstance(data, dict): + nested: dict[str, Any] = {} + for series_name, series_data in data.items(): + # Preserve _type metadata key (not an array) + if series_name == "_type": + nested["_type"] = series_data + else: + arr = to_array(series_data, f"Subplot '{name}.{series_name}'") + nested[series_name] = arr + result["subplots"][name] = nested + else: + arr = to_array(data, f"Subplot '{name}'") + result["subplots"][name] = arr return result @@ -186,7 +218,7 @@ def __init__( low: Optional[Union[pd.Series, np.ndarray]] = None, close: Optional[Union[pd.Series, np.ndarray]] = None, overlays: Optional[Dict[str, Union[pd.Series, np.ndarray]]] = None, - subplots: Optional[Dict[str, Union[pd.Series, np.ndarray]]] = None, + subplots: Optional[SubplotsInput] = None, ): # Validate input and get normalized arrays validated = validate_input(index, open, high, low, close, overlays, subplots) @@ -215,7 +247,7 @@ def close(self) -> Optional[np.ndarray]: return self._close @property def overlays(self) -> Dict[str, np.ndarray]: return self._overlays @property - def subplots(self) -> Dict[str, np.ndarray]: return self._subplots + def subplots(self) -> Dict[str, Any]: return self._subplots @property def length(self) -> int: return self._length def __len__(self) -> int: return self._length @@ -268,10 +300,27 @@ def slice_opt(arr): "subplots": {}, } - for name, data in self._overlays.items(): - result["overlays"][name] = data[start_index:end_index].tolist() + for name, overlay_info in self._overlays.items(): + # Overlay is now a dict with "data" array and optional style metadata + data_arr = overlay_info["data"] + result["overlays"][name] = { + "data": data_arr[start_index:end_index].tolist(), + "style": overlay_info.get("style", "line"), + "color": overlay_info.get("color"), + "size": overlay_info.get("size"), + } for name, data in self._subplots.items(): - result["subplots"][name] = data[start_index:end_index].tolist() + if isinstance(data, dict): + nested: dict[str, Any] = {} + for series_name, series_data in data.items(): + # Preserve _type metadata (not an array, just pass through) + if series_name == "_type": + nested["_type"] = series_data + else: + nested[series_name] = series_data[start_index:end_index].tolist() + result["subplots"][name] = nested + else: + result["subplots"][name] = data[start_index:end_index].tolist() return result diff --git a/src/pycharting/web/static/js/chart.js b/src/pycharting/web/static/js/chart.js index 53e0926..3629376 100644 --- a/src/pycharting/web/static/js/chart.js +++ b/src/pycharting/web/static/js/chart.js @@ -18,11 +18,12 @@ class PyChart { title: options.title || 'OHLC Chart', ...options }; - + this.chart = null; this.data = null; this.measurementButtonElement = null; this.exportButtonElement = null; // Track export button + this.lastCursorIdx = null; // Track last valid cursor index for value retention } /** @@ -113,14 +114,18 @@ class PyChart { * Set chart data and render * @param {Array} data - Chart data [xValues, open, high, low, close, ...overlays] * @param {Array} timestamps - Optional array of timestamps corresponding to xValues + * @param {Array} overlayLabels - Optional labels for overlay series (same order as overlays) + * @param {Array} overlayStyles - Optional style configs for overlays [{style, color, size}, ...] */ - setData(data, timestamps = null) { + setData(data, timestamps = null, overlayLabels = null, overlayStyles = null) { const prevLen = this.data ? this.data.length : null; const prevHadTimestamps = this.timestamps != null; const nowHasTimestamps = timestamps != null; - + this.data = data; this.timestamps = timestamps; + this.overlayLabels = overlayLabels; + this.overlayStyles = overlayStyles; // Rebuild chart if: // 1. Series count changed (e.g., overlays added) @@ -151,34 +156,54 @@ class PyChart { } } + /** + * Get grid color based on current theme (reads CSS variable) + */ + getGridColor() { + const style = getComputedStyle(document.body); + return style.getPropertyValue('--grid-color').trim() || '#2a2a4a'; + } + + /** + * Get axis color based on current theme (reads CSS variable) + */ + getAxisColor() { + const style = getComputedStyle(document.body); + return style.getPropertyValue('--text-secondary').trim() || '#888'; + } + /** * Helper to format x-axis values (indices) to dates */ formatDate(index) { + const barIdx = Math.round(index); + if (!this.timestamps) { - return index; + return `[${barIdx}]`; } - + if (this.data && this.data[0] && this.data[0].length > 0) { const startIndex = this.data[0][0]; const dataIndex = Math.round(index - startIndex); - + if (dataIndex >= 0 && dataIndex < this.timestamps.length) { const val = this.timestamps[dataIndex]; - + // Heuristic: Only format as date if value looks like a timestamp (milliseconds) // Threshold: Year 1980 (~3.15e11 ms) if (typeof val === 'number' && val > 315360000000) { const date = new Date(val); - return date.toLocaleString(undefined, { - month: 'numeric', day: 'numeric', - hour: '2-digit', minute: '2-digit' - }); + // Short format: "[barIdx] 10/21 14:30" for x-axis labels + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const mins = String(date.getMinutes()).padStart(2, '0'); + return `[${barIdx}] ${month}/${day} ${hours}:${mins}`; } - return val; + return `[${barIdx}] ${val}`; } } - return index; + return `[${barIdx}]`; } /** @@ -190,45 +215,127 @@ class PyChart { // Check if Open series exists and contains any non-null values const openSeries = data[1]; const hasOHLC = openSeries && Array.isArray(openSeries) && openSeries.some(v => v != null); - + + // Helper to get value with fallback to last cursor position + // When cursor leaves, v is null but we want to retain the last displayed value + const getValueWithRetention = (u, v, seriesIdx) => { + if (v != null) { + return v.toFixed(5); + } + // Cursor left chart - use last known cursor index to get value + if (self.lastCursorIdx != null && u.data && u.data[seriesIdx]) { + const lastVal = u.data[seriesIdx][self.lastCursorIdx]; + if (lastVal != null) { + return lastVal.toFixed(5); + } + } + return '-'; + }; + // Build series configuration const series = [ { label: 'Time', - value: (u, v) => self.formatDate(v) + value: (u, v) => { + if (v != null) { + return self.formatDate(v); + } + // Use last cursor index for time display too + if (self.lastCursorIdx != null && u.data && u.data[0]) { + const lastX = u.data[0][self.lastCursorIdx]; + if (lastX != null) { + return self.formatDate(lastX); + } + } + return '-'; + } }, { label: 'Open', show: false, + value: (u, v) => getValueWithRetention(u, v, 1), }, { label: 'High', show: false, + value: (u, v) => getValueWithRetention(u, v, 2), }, { label: 'Low', show: false, + value: (u, v) => getValueWithRetention(u, v, 3), }, { label: 'Close', stroke: hasOHLC ? 'transparent' : '#2196F3', width: hasOHLC ? 0 : 2, fill: 'transparent', + value: (u, v) => getValueWithRetention(u, v, 4), } ]; - + // Add overlay series (starting from index 5) if (data.length > 5) { + const defaultColors = ['#2196F3', '#FF9800', '#9C27B0', '#4CAF50']; + for (let i = 5; i < data.length; i++) { - const colors = ['#2196F3', '#FF9800', '#9C27B0', '#4CAF50']; - series.push({ - label: `Overlay ${i - 4}`, - stroke: colors[(i - 5) % colors.length], - width: 2, - }); + const overlayIdx = i - 5; + const label = (this.overlayLabels && this.overlayLabels[overlayIdx]) ? this.overlayLabels[overlayIdx] : `Overlay ${overlayIdx + 1}`; + + // Get style from overlayStyles if available, otherwise default to line + const styleConfig = (this.overlayStyles && this.overlayStyles[overlayIdx]) || { style: 'line' }; + const style = styleConfig.style || 'line'; + const customColor = styleConfig.color; + const customSize = styleConfig.size; + + // Determine rendering mode from style config + const renderAsMarker = style === 'marker'; + const renderAsDashed = style === 'dashed'; + + // Use custom color or fall back to default color palette + const stroke = customColor || defaultColors[overlayIdx % defaultColors.length]; + + // Capture seriesIdx for closure + const seriesIdx = i; + const seriesConfig = { + label, + stroke, + width: renderAsMarker ? 0 : 2, + points: renderAsMarker ? { + show: true, + size: customSize || 10, + fill: stroke, + stroke: '#ffffff', + width: 2, + } : { show: false }, + value: (u, v) => getValueWithRetention(u, v, seriesIdx), + }; + + // Add dashed line style if specified + if (renderAsDashed) { + seriesConfig.dash = [6, 4]; + } + + series.push(seriesConfig); } } - + + // Create cursor tracking plugin to track last valid cursor index + const cursorTrackingPlugin = () => ({ + hooks: { + setCursor: [ + (u) => { + // Track the last valid cursor index (when cursor is over data) + if (u.cursor.idx != null) { + self.lastCursorIdx = u.cursor.idx; + } + // Note: We intentionally do NOT reset lastCursorIdx when cursor leaves + // This allows values to persist until a new bar is hovered + } + ] + } + }); + return { ...this.options, series, @@ -242,23 +349,31 @@ class PyChart { }, axes: [ { - stroke: '#888', - grid: { stroke: '#eee', width: 1 }, + stroke: this.getAxisColor(), + grid: { stroke: this.getGridColor(), width: 1 }, + space: 100, // Minimum pixels between x-axis labels to prevent overlap + size: 40, // Fixed height for x-axis area values: (u, vals) => vals.map(v => { const idx = Math.round(v); return self.formatDate(idx); }), }, { - stroke: '#888', - grid: { stroke: '#eee', width: 1 }, - values: (u, vals) => vals.map(v => v.toFixed(2)), + stroke: this.getAxisColor(), + grid: { stroke: this.getGridColor(), width: 1 }, + size: 60, // Fixed width for y-axis area + values: (u, vals) => vals.map(v => v.toFixed(5)), } ], - plugins: hasOHLC ? [this.candlestickPlugin()] : [], + plugins: hasOHLC ? [this.candlestickPlugin(), cursorTrackingPlugin()] : [cursorTrackingPlugin()], cursor: { drag: { x: false, y: false }, - sync: { key: 'pycharting' } + sync: { key: 'pycharting' }, + focus: { prox: Infinity }, // Keep values visible when mouse leaves chart + }, + legend: { + live: true, // Update values on cursor move + show: true, } }; } @@ -541,8 +656,8 @@ class PyChart { const boxWidth = maxWidth + 20; const boxHeight = 60; - // Draw box background - overlayCtx.fillStyle = 'rgba(33, 33, 33, 0.9)'; + // Draw box background (dark mode) + overlayCtx.fillStyle = 'rgba(26, 26, 46, 0.95)'; overlayCtx.fillRect(boxX - boxWidth / 2, boxY, boxWidth, boxHeight); // Draw box border @@ -752,16 +867,16 @@ class PyChart { try { // Save original background const originalBg = this.container.style.backgroundColor; - - // Temporarily set white background for export - this.container.style.backgroundColor = '#ffffff'; + + // Temporarily set dark background for export (matching dark theme) + this.container.style.backgroundColor = '#1a1a2e'; // Dynamically load html2canvas const html2canvas = (await import('https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/+esm')).default; // Capture the entire container (chart + overlays) const canvas = await html2canvas(this.container, { - backgroundColor: '#ffffff', + backgroundColor: '#1a1a2e', scale: 2, // 2x resolution for better quality logging: false, useCORS: true @@ -837,17 +952,18 @@ class PyChart { top: 10px; right: 10px; padding: 8px 12px; - background: rgba(255, 255, 255, 0.9); - border: 1px solid #ccc; + background: rgba(30, 30, 50, 0.95); + border: 1px solid #3a3a5e; border-radius: 4px; cursor: pointer; font-size: 14px; z-index: 1000; - box-shadow: 0 2px 4px rgba(0,0,0,0.1); + box-shadow: 0 2px 4px rgba(0,0,0,0.3); + color: #e0e0e0; `; - - btn.onmouseover = () => btn.style.background = 'rgba(255, 255, 255, 1)'; - btn.onmouseout = () => btn.style.background = 'rgba(255, 255, 255, 0.9)'; + + btn.onmouseover = () => btn.style.background = 'rgba(40, 40, 70, 1)'; + btn.onmouseout = () => btn.style.background = 'rgba(30, 30, 50, 0.95)'; btn.onclick = () => { const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-'); diff --git a/src/pycharting/web/static/js/viewport.js b/src/pycharting/web/static/js/viewport.js index 4f3ceb9..fe8073d 100644 --- a/src/pycharting/web/static/js/viewport.js +++ b/src/pycharting/web/static/js/viewport.js @@ -77,7 +77,7 @@ class ViewportManager { try { subplot.setScale('x', { min, max }); } catch (e) { - // Ignore sync errors per subplot + // Ignore sync errors } }); } @@ -191,8 +191,27 @@ class ViewportManager { */ async updateViewport() { const range = this.calculateVisibleRange(); - - if (!range || !this.needsFetch(range)) { + + if (!range) { + return; + } + + // Even if we don't need to fetch, update subplots with cached data + // This ensures subplots stay in sync when panning within cached range + if (!this.needsFetch(range) && this.dataCache.data) { + this.subplotCallbacks.forEach((cb) => { + if (typeof cb === 'function') { + // Pass cached data with current indices + const data = { ...this.dataCache.data }; + data.xIndices = data.index.map((_, i) => (data.start_index || 0) + i); + data.timestamps = data.index; + cb(data, this); + } + }); + return; + } + + if (!this.needsFetch(range)) { return; } @@ -255,15 +274,31 @@ class ViewportManager { ensureArray(data.close) ]; - // Add overlays if present + // Add overlays if present (preserve keys for labeling/styling) + // New format: overlay is {data: [], style: "line"|"marker"|"dashed", color: "#hex", size: N} + const overlayLabels = []; + const overlayStyles = []; if (data.overlays) { - Object.values(data.overlays).forEach(overlay => { - chartData.push(overlay); + Object.entries(data.overlays).forEach(([label, overlay]) => { + overlayLabels.push(label); + // Handle both new format (object with data) and legacy format (array) + if (overlay && typeof overlay === 'object' && overlay.data) { + chartData.push(overlay.data); + overlayStyles.push({ + style: overlay.style || 'line', + color: overlay.color || null, + size: overlay.size || null + }); + } else { + // Legacy format: overlay is just an array + chartData.push(overlay); + overlayStyles.push({ style: 'line', color: null, size: null }); + } }); } - + // Update chart with indices as X, and pass timestamps for formatting - this.chart.setData(chartData, timestamps); + this.chart.setData(chartData, timestamps, overlayLabels, overlayStyles); // Ensure listeners are set up (needed if chart was just created/recreated) this.setupEventListeners(); diff --git a/src/pycharting/web/static/viewport-demo.html b/src/pycharting/web/static/viewport-demo.html index f7c2f83..733a43d 100644 --- a/src/pycharting/web/static/viewport-demo.html +++ b/src/pycharting/web/static/viewport-demo.html @@ -1,619 +1,1637 @@ +
- - -