Skip to content

fix: persist legend in HTML export#160

Merged
giswqs merged 1 commit intomainfrom
fix/legend-html-export
Jan 31, 2026
Merged

fix: persist legend in HTML export#160
giswqs merged 1 commit intomainfrom
fix/legend-html-export

Conversation

@giswqs
Copy link
Copy Markdown
Member

@giswqs giswqs commented Jan 31, 2026

Summary

Fixes #157 — legends added via add_legend() now appear in exported HTML files.

Problem

add_legend() builds the legend UI using ipywidgets (widgets.HTML items inside a widgets.VBox), then wraps it in a widget_panel control via add_widget_control(). This works perfectly in Jupyter, but when exporting to HTML via to_html(), the HTML template had no handler for the widget_panel control type — so legends were silently dropped.

Solution

anymap/maplibre.py — At the end of add_legend(), after the existing ipywidgets path, store a separate serializable legend control entry in _controls containing the raw legend configuration (title, labels, colors, shape_type, bg_color, fontsize, collapsed state, header styling, max_height, etc.). This entry travels through to_html()_generate_html_template() → the HTML template via the map_state JSON, without affecting the Jupyter rendering path.

anymap/templates/maplibre_template.html — Added a case 'legend': handler in the control restoration switch that reconstructs the legend as a pure HTML/CSS/JS collapsible panel:

  • Toggle button that expands to show the legend (respects collapsed setting)
  • Header bar with title and close button (supports custom header_color / header_text_color)
  • Scrollable content area with legend items using the correct shape type (rectangle, circle, or line)
  • Honors all styling options: bg_color, fontsize, max_height

Also 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_dict and labels/colors input styles
  • Built-in legends (NWI, NLCD)
  • All shape types: rectangle, circle, line
  • Custom title, icon, fontsize, bg_color
  • Collapsed/expanded initial state
  • Custom header colors
  • Max height with scrollable overflow

Testing

All 111 existing tests pass. Verified with multiple legend configurations that the exported HTML contains the legend markup and renders correctly.

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
Copilot AI review requested due to automatic review settings January 31, 2026 06:34
@github-actions
Copy link
Copy Markdown

@github-actions github-actions Bot temporarily deployed to pull request January 31, 2026 06:35 Inactive
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_panel controls 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;';
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +1308 to +1322
// 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;';
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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;';

Copilot uses AI. Check for mistakes.
Comment on lines +1332 to +1344
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;';
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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;';

Copilot uses AI. Check for mistakes.
Comment thread anymap/maplibre.py
Comment on lines +5663 to +5668
try:
safe_fs = int(fontsize)
if not (1 <= safe_fs <= 100):
safe_fs = 14
except (ValueError, TypeError):
safe_fs = 14
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread anymap/maplibre.py
"icon": icon,
"collapsed": collapsed,
"fontsize": safe_fs,
"max_height": max_height,
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@giswqs giswqs merged commit a4217eb into main Jan 31, 2026
14 checks passed
@giswqs giswqs deleted the fix/legend-html-export branch January 31, 2026 15:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Legends not displayed in HTML FIles

2 participants