Conversation
Legends added via add_legend() use ipywidgets internally, which only render inside Jupyter. When exporting to HTML via to_html(), the widget_panel control type was unrecognized by the HTML template, so legends silently disappeared. Changes: - add_legend() now stores a serializable 'legend' control entry in _controls alongside the ipywidgets version, containing the raw legend configuration (title, labels, colors, shape_type, etc.) - The HTML template gains a 'legend' case that reconstructs a collapsible legend panel using pure HTML/CSS/JS — matching the Jupyter appearance (toggle button, header with close, scrollable items, shape types) - widget_panel controls are silently skipped during HTML export instead of logging 'Unknown control type' warnings Closes #157
|
🚀 Deployed on https://697da2bb71e11de5ab8cbd4f--opengeos.netlify.app |
There was a problem hiding this comment.
Pull request overview
This PR fixes issue #157 by enabling legends created via add_legend() to persist in HTML exports. Previously, legends were built using ipywidgets which only work in Jupyter environments and were silently dropped during HTML export.
Changes:
- Added serializable legend control storage in
add_legend()to capture legend configuration (title, labels, colors, styling options) alongside the existing ipywidgets path - Implemented pure HTML/CSS/JS legend rendering in the HTML template with collapsible panel, proper styling, and all shape types (rectangle, circle, line)
- Added silent skip for
widget_panelcontrols in HTML export to prevent console warnings
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| anymap/maplibre.py | Stores legend configuration as a dedicated control entry for HTML export after creating the ipywidgets version for Jupyter |
| anymap/templates/maplibre_template.html | Adds legend case handler to reconstruct legends as HTML/CSS/JS controls and widget_panel skip logic |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Panel (shown when expanded) | ||
| const panel = document.createElement('div'); | ||
| panel.className = 'anymap-legend-panel'; | ||
| panel.style.cssText = 'display: none; background: ' + bgColor + '; border-radius: 4px; box-shadow: 0 0 0 2px rgba(0,0,0,.1); min-width: 120px; max-width: 360px;'; |
There was a problem hiding this comment.
Potential CSS injection vulnerability: The bgColor value is directly concatenated into the style string without validation or sanitization. A malicious bgColor value like white; position: fixed; z-index: 9999; could break the layout or inject arbitrary CSS. Consider validating that bgColor contains only safe CSS color values (hex codes, rgb/rgba, named colors) or sanitizing the value before use.
| // Panel (shown when expanded) | ||
| const panel = document.createElement('div'); | ||
| panel.className = 'anymap-legend-panel'; | ||
| panel.style.cssText = 'display: none; background: ' + bgColor + '; border-radius: 4px; box-shadow: 0 0 0 2px rgba(0,0,0,.1); min-width: 120px; max-width: 360px;'; | ||
|
|
||
| // Header | ||
| const header = document.createElement('div'); | ||
| header.style.cssText = 'display: flex; justify-content: space-between; align-items: center; padding: 6px 10px; border-bottom: 1px solid #ddd; cursor: pointer; border-radius: 4px 4px 0 0;' + | ||
| (headerColor ? ' background: ' + headerColor + ';' : ' background: #f0f0f0;'); | ||
| const headerLabel = document.createElement('span'); | ||
| headerLabel.textContent = legendTitle; | ||
| headerLabel.style.cssText = 'font-weight: bold; font-size: 13px; color: ' + (headerTextColor || '#333') + ';'; | ||
| const closeBtn = document.createElement('span'); | ||
| closeBtn.innerHTML = '×'; | ||
| closeBtn.style.cssText = 'cursor: pointer; font-size: 16px; color: ' + (headerTextColor || '#666') + '; margin-left: 12px;'; |
There was a problem hiding this comment.
Potential CSS injection vulnerability: The headerColor and headerTextColor values are directly concatenated into the style string without validation or sanitization. A malicious headerColor value like red; position: fixed; could inject arbitrary CSS properties. Consider validating that these values contain only safe CSS color values or sanitizing them before use.
| // Panel (shown when expanded) | |
| const panel = document.createElement('div'); | |
| panel.className = 'anymap-legend-panel'; | |
| panel.style.cssText = 'display: none; background: ' + bgColor + '; border-radius: 4px; box-shadow: 0 0 0 2px rgba(0,0,0,.1); min-width: 120px; max-width: 360px;'; | |
| // Header | |
| const header = document.createElement('div'); | |
| header.style.cssText = 'display: flex; justify-content: space-between; align-items: center; padding: 6px 10px; border-bottom: 1px solid #ddd; cursor: pointer; border-radius: 4px 4px 0 0;' + | |
| (headerColor ? ' background: ' + headerColor + ';' : ' background: #f0f0f0;'); | |
| const headerLabel = document.createElement('span'); | |
| headerLabel.textContent = legendTitle; | |
| headerLabel.style.cssText = 'font-weight: bold; font-size: 13px; color: ' + (headerTextColor || '#333') + ';'; | |
| const closeBtn = document.createElement('span'); | |
| closeBtn.innerHTML = '×'; | |
| closeBtn.style.cssText = 'cursor: pointer; font-size: 16px; color: ' + (headerTextColor || '#666') + '; margin-left: 12px;'; | |
| // Helper to sanitize CSS color values used in inline styles | |
| function sanitizeCssColor(value, fallback) { | |
| if (typeof value !== 'string') { | |
| return fallback; | |
| } | |
| // Allow common characters used in color values (hex, rgb/rgba, hsl/hsla, var(), etc.) | |
| const cleaned = value.replace(/[^#(),.%a-zA-Z0-9\s+-]/g, ''); | |
| const trimmed = cleaned.trim(); | |
| return trimmed || fallback; | |
| } | |
| // Panel (shown when expanded) | |
| const panel = document.createElement('div'); | |
| panel.className = 'anymap-legend-panel'; | |
| panel.style.cssText = 'display: none; background: ' + sanitizeCssColor(bgColor, 'white') + '; border-radius: 4px; box-shadow: 0 0 0 2px rgba(0,0,0,.1); min-width: 120px; max-width: 360px;'; | |
| // Header | |
| const header = document.createElement('div'); | |
| header.style.cssText = 'display: flex; justify-content: space-between; align-items: center; padding: 6px 10px; border-bottom: 1px solid #ddd; cursor: pointer; border-radius: 4px 4px 0 0;' + | |
| (headerColor ? ' background: ' + sanitizeCssColor(headerColor, '#f0f0f0') + ';' : ' background: #f0f0f0;'); | |
| const headerLabel = document.createElement('span'); | |
| headerLabel.textContent = legendTitle; | |
| headerLabel.style.cssText = 'font-weight: bold; font-size: 13px; color: ' + sanitizeCssColor(headerTextColor || '#333', '#333') + ';'; | |
| const closeBtn = document.createElement('span'); | |
| closeBtn.innerHTML = '×'; | |
| closeBtn.style.cssText = 'cursor: pointer; font-size: 16px; color: ' + sanitizeCssColor(headerTextColor || '#666', '#666') + '; margin-left: 12px;'; |
| for (let i = 0; i < legendLabels.length; i++) {{ | ||
| const label = legendLabels[i]; | ||
| const color = legendColors[i] || '#ccc'; | ||
| const row = document.createElement('div'); | ||
| row.style.cssText = 'margin: 0 0 4px 0; line-height: 1.4; white-space: nowrap; font-size: ' + fontSize + 'px;'; | ||
|
|
||
| const shape = document.createElement('span'); | ||
| if (shapeType === 'circle') {{ | ||
| shape.style.cssText = 'display: inline-block; width: 20px; height: 20px; background-color: ' + color + '; border-radius: 50%; margin-right: 8px; vertical-align: middle;'; | ||
| }} else if (shapeType === 'line') {{ | ||
| shape.style.cssText = 'display: inline-block; width: 20px; height: 3px; background-color: ' + color + '; margin-right: 8px; vertical-align: middle;'; | ||
| }} else {{ | ||
| shape.style.cssText = 'display: inline-block; width: 20px; height: 20px; background-color: ' + color + '; margin-right: 8px; vertical-align: middle;'; |
There was a problem hiding this comment.
Potential CSS injection vulnerability: The color value from legendColors is directly concatenated into the style string without validation. While colors are normalized to add '#' prefix in Python (line 5542), there's no validation that the color string itself is safe. A malicious color value like red; background-image: url(javascript:alert(1)); could inject arbitrary CSS. Consider validating that color values match expected patterns (e.g., hex codes, rgb/rgba, named colors) before they reach the HTML template.
| for (let i = 0; i < legendLabels.length; i++) {{ | |
| const label = legendLabels[i]; | |
| const color = legendColors[i] || '#ccc'; | |
| const row = document.createElement('div'); | |
| row.style.cssText = 'margin: 0 0 4px 0; line-height: 1.4; white-space: nowrap; font-size: ' + fontSize + 'px;'; | |
| const shape = document.createElement('span'); | |
| if (shapeType === 'circle') {{ | |
| shape.style.cssText = 'display: inline-block; width: 20px; height: 20px; background-color: ' + color + '; border-radius: 50%; margin-right: 8px; vertical-align: middle;'; | |
| }} else if (shapeType === 'line') {{ | |
| shape.style.cssText = 'display: inline-block; width: 20px; height: 3px; background-color: ' + color + '; margin-right: 8px; vertical-align: middle;'; | |
| }} else {{ | |
| shape.style.cssText = 'display: inline-block; width: 20px; height: 20px; background-color: ' + color + '; margin-right: 8px; vertical-align: middle;'; | |
| function isSafeColor(color) {{ | |
| if (typeof color !== 'string') {{ | |
| return false; | |
| }} | |
| const trimmed = color.trim(); | |
| // Allow: | |
| // - Hex (#rgb, #rgba, #rrggbb, #rrggbbaa) | |
| // - rgb()/rgba() with 0–255 components and 0–1 alpha | |
| // - hsl()/hsla() with basic numeric ranges | |
| // - Alphabetic named colors (e.g., 'red', 'blue') | |
| const colorPattern = /^(#([0-9a-fA-F]{{3}}|[0-9a-fA-F]{{4}}|[0-9a-fA-F]{{6}}|[0-9a-fA-F]{{8}})|rgb\(\s*\d{{1,3}}\s*,\s*\d{{1,3}}\s*,\s*\d{{1,3}}\s*\)|rgba\(\s*\d{{1,3}}\s*,\s*\d{{1,3}}\s*,\s*\d{{1,3}}\s*,\s*(0|1|0?\.\d+)\s*\)|hsl\(\s*\d{{1,3}}\s*,\s*\d{{1,3}}%\s*,\s*\d{{1,3}}%\s*\)|hsla\(\s*\d{{1,3}}\s*,\s*\d{{1,3}}%\s*,\s*\d{{1,3}}%\s*,\s*(0|1|0?\.\d+)\s*\)|[a-zA-Z]+)$/; | |
| return colorPattern.test(trimmed); | |
| }} | |
| for (let i = 0; i < legendLabels.length; i++) {{ | |
| const label = legendLabels[i]; | |
| const rawColor = legendColors[i] || '#ccc'; | |
| const safeColor = isSafeColor(rawColor) ? rawColor.trim() : '#ccc'; | |
| const row = document.createElement('div'); | |
| row.style.cssText = 'margin: 0 0 4px 0; line-height: 1.4; white-space: nowrap; font-size: ' + fontSize + 'px;'; | |
| const shape = document.createElement('span'); | |
| if (shapeType === 'circle') {{ | |
| shape.style.cssText = 'display: inline-block; width: 20px; height: 20px; background-color: ' + safeColor + '; border-radius: 50%; margin-right: 8px; vertical-align: middle;'; | |
| }} else if (shapeType === 'line') {{ | |
| shape.style.cssText = 'display: inline-block; width: 20px; height: 3px; background-color: ' + safeColor + '; margin-right: 8px; vertical-align: middle;'; | |
| }} else {{ | |
| shape.style.cssText = 'display: inline-block; width: 20px; height: 20px; background-color: ' + safeColor + '; margin-right: 8px; vertical-align: middle;'; |
| try: | ||
| safe_fs = int(fontsize) | ||
| if not (1 <= safe_fs <= 100): | ||
| safe_fs = 14 | ||
| except (ValueError, TypeError): | ||
| safe_fs = 14 |
There was a problem hiding this comment.
Duplicated validation logic: The fontsize validation logic is duplicated both in the widget display section (lines 5557-5562) and in the export configuration section (lines 5664-5668). Consider extracting this into a helper function or validating once at the beginning of the method to avoid redundancy and potential inconsistencies.
| "icon": icon, | ||
| "collapsed": collapsed, | ||
| "fontsize": safe_fs, | ||
| "max_height": max_height, |
There was a problem hiding this comment.
Missing validation for max_height parameter: While max_height is type-hinted as int with a default of 380, there's no runtime validation before storing it in the control configuration. If a user passes a non-integer value or an unreasonably large/small value, it could cause issues in the HTML template where it's used in CSS calculations (line 1328: Math.max(100, maxHeight - 60)). Consider adding validation similar to the fontsize validation to ensure max_height is a reasonable integer value.
Summary
Fixes #157 — legends added via
add_legend()now appear in exported HTML files.Problem
add_legend()builds the legend UI using ipywidgets (widgets.HTMLitems inside awidgets.VBox), then wraps it in awidget_panelcontrol viaadd_widget_control(). This works perfectly in Jupyter, but when exporting to HTML viato_html(), the HTML template had no handler for thewidget_panelcontrol type — so legends were silently dropped.Solution
anymap/maplibre.py— At the end ofadd_legend(), after the existing ipywidgets path, store a separate serializablelegendcontrol entry in_controlscontaining the raw legend configuration (title, labels, colors, shape_type, bg_color, fontsize, collapsed state, header styling, max_height, etc.). This entry travels throughto_html()→_generate_html_template()→ the HTML template via themap_stateJSON, without affecting the Jupyter rendering path.anymap/templates/maplibre_template.html— Added acase 'legend':handler in the control restoration switch that reconstructs the legend as a pure HTML/CSS/JS collapsible panel:collapsedsetting)header_color/header_text_color)rectangle,circle, orline)bg_color,fontsize,max_heightAlso added a
case 'widget_panel':that silently skips, preventing "Unknown control type" console warnings for any other widget-panel controls that can't be serialized.What's supported
All
add_legend()features work in HTML export:legend_dictandlabels/colorsinput stylesNWI,NLCD)rectangle,circle,lineTesting
All 111 existing tests pass. Verified with multiple legend configurations that the exported HTML contains the legend markup and renders correctly.