From 9da803fcdcf960b4092013cf91dccc98131426bf Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:45:05 +1030 Subject: [PATCH 01/43] Add traffic colour presets and high-load link pulsing --- css/editor.css | 23 ++++ css/weathermap.css | 6 + js/editor.js | 130 ++++++++++++++++++ js/weathermap.js | 93 +++++++++++++ lib/WeatherMap.animation.php | 55 ++++++++ lib/WeatherMap.class.php | 4 +- lib/WeatherMap.functions.php | 5 + lib/WeatherMapLink.class.php | 3 + lib/editor.actions.php | 29 ++++ lib/editor.colours.php | 85 ++++++++++++ lib/editor.new-map-preset.php | 1 + locales/po/cacti.pot | 40 ++++-- tests/Browser/TrafficGlowTest.cjs | 17 +++ tests/Support/EditorAssetsRegression.php | 67 +++++++++ .../Support/NewMapPresetEngineRegression.php | 3 + tests/Support/TrafficAnimationRegression.php | 61 ++++++++ tests/Unit/EditorAssetsTest.php | 19 +++ tests/Unit/TrafficAnimationTest.php | 19 +++ weathermap-cacti-plugin-editor.php | 105 +++++++------- 19 files changed, 701 insertions(+), 64 deletions(-) create mode 100644 lib/WeatherMap.animation.php create mode 100644 lib/editor.colours.php create mode 100644 tests/Browser/TrafficGlowTest.cjs create mode 100644 tests/Support/EditorAssetsRegression.php create mode 100644 tests/Support/TrafficAnimationRegression.php create mode 100644 tests/Unit/EditorAssetsTest.php create mode 100644 tests/Unit/TrafficAnimationTest.php diff --git a/css/editor.css b/css/editor.css index 4f8571b1..b967d800 100644 --- a/css/editor.css +++ b/css/editor.css @@ -316,3 +316,26 @@ body#mainView #wm-node-delete-bar { min-height: 32px; } .ui-dialog.wm-editor-dialog { overflow: visible; } + +/* Colour and image libraries. */ +#wm-scale-rows input { width: 85px; box-sizing: border-box; } +#wm-scale-rows .wm-range-tag { width: 110px; } +.wm-asset-colour { display: inline-flex; flex-direction: column; gap: 4px; } +.wm-asset-colour select { max-width: 180px; } +.wm-asset-colour summary { font-size: 0.9em; cursor: pointer; } +.wm-asset-delete { color: #c62828 !important; background: #fff !important; border: 1px solid #c62828 !important; font-size: 20px; } + +.wm-asset-swatches { display: flex; gap: 4px; } +.wm-asset-swatches button { width: 20px; height: 20px; border: 1px solid currentColor; cursor: pointer; padding: 0; } + +#wm-traffic-preview { display: flex; flex-wrap: wrap; gap: 10px; margin: 12px 0; } +.wm-traffic-band { display: inline-flex; flex-direction: column; align-items: center; gap: 4px; } +.wm-traffic-swatch { width: 55px; height: 22px; border: 1px solid #777; } +#wm-traffic-advanced > summary { cursor: pointer; margin: 14px 0; } + +/* Use Cacti's own theme heading colours in every grouped editor dialog. */ +.dlgProperties .wm-editor-section { display: table; width: 100%; box-sizing: border-box; margin: 10px 0 6px; } +.dlgProperties table .wm-editor-section { margin: 0; } +#wm-colours-error:not(:empty) { color: inherit; border-left: 3px solid #c62828; padding-left: 8px; font-weight: bold; } + +#dlgColours > .dlgBody { display: block; } diff --git a/css/weathermap.css b/css/weathermap.css index d16e8f34..22bf48a7 100644 --- a/css/weathermap.css +++ b/css/weathermap.css @@ -121,3 +121,9 @@ button.wm-cycle-control.fas { font-family: "Font Awesome 5 Free"; font-weight: 9 /* Keep gallery headings readable against the theme title bar. */ .cactiTableTitleRow.wm-gallery-heading { color: var(--text-color-highlight, #fff); } + +.wm-traffic-glow { position: absolute; pointer-events: none; overflow: visible; } +.wm-traffic-pulse { opacity: .25; animation: wm-traffic-pulse 2.5s ease-in-out infinite; } +@keyframes wm-traffic-pulse { 0%, 100% { opacity: .2; } 50% { opacity: 1; } } +.wm-glow-paused .wm-traffic-pulse { animation: none; opacity: .65; } +@media (prefers-reduced-motion: reduce) { .wm-traffic-pulse { animation: none; opacity: .65; } } diff --git a/js/editor.js b/js/editor.js index dad82c1a..c4f6bfb2 100644 --- a/js/editor.js +++ b/js/editor.js @@ -690,6 +690,8 @@ function manage_colours() { $('#action').val('set_map_colours'); show_dialog('dlgColours'); + $('#dlgColours').dialog('option', 'width', Math.min(1100, $(window).width() - 40)); + wmOpenColours(); } function manage_images() { @@ -904,6 +906,11 @@ function map_properties() { hide_all_dialogs(); + var background = wmAssetColour($('#map_background_colour').val(), false, true, true); + $('#wm-map-background-colour').empty().append(background); + background.on('input change selectmenuchange', function() { + $('#map_background_colour').val(background.find('.wm-colour-value').val()); + }); $('#action').val('set_map_properties'); show_dialog('dlgMapProperties'); @@ -1663,3 +1670,126 @@ function wmFriendlyLinkNames() { function wmDecodeEditorUrl(value) { return String(value || '').replace(/&/g, '&'); } +var wmAssetPalette = {Black: '#000000', White: '#ffffff', Blue: '#0000ff', Red: '#cc0000', Green: '#008000', Purple: '#800080', Orange: '#b35a00', Grey: '#666666', None: 'none'}; +function wmAssetColour(value, allowEmpty, swatches, solid) { + var box = $('
'), select = $('').val(/^#[0-9a-f]{6}$/i.test(value) ? value : '#000000').appendTo(advanced); + picker.on('input', function() { + var colour = this.value; + if (!select.find('option').filter(function() { return this.value === colour; }).length) select.append($('
@@ -700,7 +717,7 @@
@@ -736,7 +753,7 @@ @@ -745,7 +762,7 @@ @@ -822,37 +839,29 @@
-
-
-
- Nothing in here works yet. The aim is to have a nice color picker somehow. -
-
-
+
keyfont); ?>
-
+
-
+
- - - - - - - - - - - - - -
Background Color
Link Outline Color
Scale ColorsSome pleasant way to design the bandwidth color scale goes in here???
-
-
-
- Cancel - Save -
-
-
- In the future, this form will allow you to set the various color variables at the Map level. For now, you can control these through a direct modification of your Weathermap configuration files. -
+
+ +
Link Appearance
+
Link Border Colour
+ +

Links at 80% utilisation or above pulse in their traffic colour.

+
Traffic Load Colours
+ + +

Colours show how much of a link's bandwidth is being used. For example, 75% means three quarters of its capacity.

+
+

Presets replace the DEFAULT traffic scale when you Save. Other named scales are kept.

+
Advanced settings +

Each row is a colour range. From and To are its limits, Colour is the fill, and Gradient End blends towards another colour. Label adds optional legend text. Scale names group ranges; DEFAULT is the usual traffic scale. Custom scales can use other units.

+
ScaleFrom (usually %)To (usually %)ColourGradient EndLabel
+ +
+
+ Cancel + +
+
Scale ranges use the units configured for each link or node; traffic links normally use percentages.
From 5ba413352bd8d97536a944fc8c48d5291223ed78 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:52:57 +1030 Subject: [PATCH 02/43] Validate all scale users and omit unused pulse metadata --- js/editor.js | 18 +- lib/WeatherMap.animation.php | 59 +++-- lib/editor.actions.php | 34 +-- lib/editor.colours.php | 24 +- lib/editor.inc.php | 11 + locales/po/cacti.pot | 264 ++++++++++++++++++- tests/Support/EditorAssetsRegression.php | 53 +++- tests/Support/TrafficAnimationRegression.php | 130 +++++---- tests/Unit/EditorAssetsTest.php | 46 +++- tests/Unit/TrafficAnimationTest.php | 46 +++- weathermap-cacti-plugin-editor.php | 44 ++-- 11 files changed, 545 insertions(+), 184 deletions(-) diff --git a/js/editor.js b/js/editor.js index c4f6bfb2..df1857d2 100644 --- a/js/editor.js +++ b/js/editor.js @@ -1673,22 +1673,22 @@ function wmDecodeEditorUrl(value) { var wmAssetPalette = {Black: '#000000', White: '#ffffff', Blue: '#0000ff', Red: '#cc0000', Green: '#008000', Purple: '#800080', Orange: '#b35a00', Grey: '#666666', None: 'none'}; function wmAssetColour(value, allowEmpty, swatches, solid) { var box = $('
'), select = $('').val(/^#[0-9a-f]{6}$/i.test(value) ? value : '#000000').appendTo(advanced); picker.on('input', function() { var colour = this.value; - if (!select.find('option').filter(function() { return this.value === colour; }).length) select.append($('
- Cancel - + +
-
Scale ranges use the units configured for each link or node; traffic links normally use percentages.
+
From 6ba3fc15bc1cb0074239b021b983407243bf0619 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Mon, 12 Oct 2026 01:24:01 +1030 Subject: [PATCH 03/43] Fix high-traffic SVG glow rendering in Safari --- CHANGELOG.md | 2 ++ js/weathermap.js | 32 +++++++++++++++++++++++++++++-- tests/Browser/TrafficGlowTest.cjs | 9 +++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d13427ee..77d8d801 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ IMPORTANT NOTE: This version only works on CACTI 1.x++! --- develop --- +* Fix Safari rendering of high-traffic link glows using native SVG filters + * issue#299: Keep editor picker results open when browsing and allow changing an existing selection * feature#298: Fit or stretch background images within the map canvas diff --git a/js/weathermap.js b/js/weathermap.js index d1a28939..5885dae3 100644 --- a/js/weathermap.js +++ b/js/weathermap.js @@ -129,6 +129,8 @@ var waitForFinalEvent = (function () { var maskId = 'wm-glow-mask-' + (++maskSequence); mask.setAttribute('id', maskId); mask.setAttribute('maskUnits', 'userSpaceOnUse'); + mask.setAttribute('maskContentUnits', 'userSpaceOnUse'); + mask.setAttribute('mask-type', 'luminance'); mask.setAttribute('x', '-30'); mask.setAttribute('y', '-30'); mask.setAttribute('width', data.width + 60); mask.setAttribute('height', data.height + 60); var background = document.createElementNS(ns, 'rect'); @@ -145,7 +147,7 @@ var waitForFinalEvent = (function () { defs.appendChild(mask); svg.appendChild(defs); var arrows = document.createElementNS(ns, 'g'); arrows.setAttribute('mask', 'url(#' + maskId + ')'); svg.appendChild(arrows); - data.regions.forEach(function(region) { + data.regions.forEach(function(region, index) { if (!/^#[0-9a-f]{6}$/i.test(region.colour) || !Array.isArray(region.points) || region.points.length < 3 || !region.points.every(function(p) { return Array.isArray(p) && p.length === 2 && p.every(Number.isFinite); })) return; var polygon = document.createElementNS(ns, 'polygon'); polygon.setAttribute('points', region.points.map(function(p) { return p.join(','); }).join(' ')); @@ -153,7 +155,33 @@ var waitForFinalEvent = (function () { polygon.setAttribute('stroke', region.colour); polygon.setAttribute('stroke-width', '2'); polygon.setAttribute('stroke-linejoin', 'round'); - polygon.style.filter = 'drop-shadow(0 0 2px ' + region.colour + ') drop-shadow(0 0 5px ' + region.colour + ') drop-shadow(0 0 9px ' + region.colour + ')'; + // SVG primitives avoid WebKit's CSS drop-shadow failures on SVG shapes. + var filter = document.createElementNS(ns, 'filter'); + var filterId = maskId + '-filter-' + index; + var xs = region.points.map(function(p) { return p[0]; }); + var ys = region.points.map(function(p) { return p[1]; }); + filter.setAttribute('id', filterId); + filter.setAttribute('filterUnits', 'userSpaceOnUse'); + filter.setAttribute('x', Math.min.apply(null, xs) - 30); + filter.setAttribute('y', Math.min.apply(null, ys) - 30); + filter.setAttribute('width', Math.max.apply(null, xs) - Math.min.apply(null, xs) + 60); + filter.setAttribute('height', Math.max.apply(null, ys) - Math.min.apply(null, ys) + 60); + filter.setAttribute('color-interpolation-filters', 'sRGB'); + var merge = document.createElementNS(ns, 'feMerge'); + [2, 5, 9].forEach(function(radius) { + var blur = document.createElementNS(ns, 'feGaussianBlur'); + var result = 'glow-' + radius; + blur.setAttribute('in', 'SourceGraphic'); + blur.setAttribute('stdDeviation', radius); + blur.setAttribute('result', result); + filter.appendChild(blur); + var layer = document.createElementNS(ns, 'feMergeNode'); + layer.setAttribute('in', result); merge.appendChild(layer); + }); + var outline = document.createElementNS(ns, 'feMergeNode'); + outline.setAttribute('in', 'SourceGraphic'); merge.appendChild(outline); + filter.appendChild(merge); defs.appendChild(filter); + polygon.setAttribute('filter', 'url(#' + filterId + ')'); polygon.classList.add('wm-traffic-pulse'); arrows.appendChild(polygon); }); diff --git a/tests/Browser/TrafficGlowTest.cjs b/tests/Browser/TrafficGlowTest.cjs index 137fcb50..dd9e016f 100644 --- a/tests/Browser/TrafficGlowTest.cjs +++ b/tests/Browser/TrafficGlowTest.cjs @@ -8,6 +8,15 @@ const {JSDOM}=require('jsdom'),jquery=require('jquery'),fs=require('node:fs'),pa const source=fs.readFileSync(path.join(__dirname,'../../js/weathermap.js'),'utf8');w.eval(source.slice(source.indexOf('// Optional pulsing glow'))); await new Promise(r=>w.setTimeout(r,20)); assert.equal(active.querySelectorAll('.wm-traffic-pulse').length,1);assert.equal(active.querySelector('polygon.wm-traffic-pulse').getAttribute('stroke'),'#ff8000'); + const polygon=active.querySelector('polygon.wm-traffic-pulse'); + const filter=active.querySelector('filter'); + assert.equal(polygon.style.filter,''); + assert.equal(polygon.getAttribute('filter'),'url(#'+filter.id+')'); + assert.equal(filter.getAttribute('filterUnits'),'userSpaceOnUse'); + assert.equal(filter.getAttribute('x'),'-20');assert.equal(filter.getAttribute('width'),'80'); + assert.deepEqual([...filter.querySelectorAll('feGaussianBlur')].map(b=>b.getAttribute('stdDeviation')),['2','5','9']); + assert.equal(filter.querySelectorAll('feMergeNode').length,4); + assert.equal(active.querySelector('mask').getAttribute('mask-type'),'luminance'); assert.equal(active.querySelectorAll('mask polygon').length,1);assert.equal(active.querySelector('area').getAttribute('href'),'graph.php'); assert.equal(disabled.querySelector('svg'),null);assert.equal(quiet.querySelector('svg'),null); $(w.document).trigger('ajaxComplete');assert.equal(active.querySelectorAll('svg').length,1);assert.equal(observers.length,1); From 7ad49b430c835410a29919eacdd8685fb0e648df Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Mon, 12 Oct 2026 01:30:27 +1030 Subject: [PATCH 04/43] Preserve custom scales and clean up detached traffic glow maps --- CHANGELOG.md | 2 ++ js/editor.js | 2 +- js/weathermap.js | 6 ++++++ lib/editor.colours.php | 10 ++++++---- locales/po/cacti.pot | 2 +- tests/Browser/TrafficGlowTest.cjs | 2 +- tests/Support/EditorAssetsRegression.php | 14 ++++++++++++++ 7 files changed, 31 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77d8d801..cb2392fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ IMPORTANT NOTE: This version only works on CACTI 1.x++! --- develop --- +* Add traffic colour presets and optional high-load link pulsing + * Fix Safari rendering of high-traffic link glows using native SVG filters * issue#299: Keep editor picker results open when browsing and allow changing an existing selection diff --git a/js/editor.js b/js/editor.js index df1857d2..b9008d4f 100644 --- a/js/editor.js +++ b/js/editor.js @@ -1703,7 +1703,7 @@ function wmColourRequest(action, extra, callback, errorId) { function wmAssetScaleRow(row) { var tr = $('').appendTo('#wm-scale-rows'); ['scale', 'bottom', 'top'].forEach(function(key) { - $('').append($('').attr('type', key === 'scale' ? 'text' : 'number').attr('step', 'any').attr('min', '0').addClass('wm-range-' + key).val(row[key])).appendTo(tr); + $('').append($('').attr('type', key === 'scale' ? 'text' : 'number').attr('step', 'any').addClass('wm-range-' + key).val(row[key])).appendTo(tr); }); $('').append(wmAssetColour(row.colour, false)).appendTo(tr); $('').append(wmAssetColour(row.end || '', true)).appendTo(tr); diff --git a/js/weathermap.js b/js/weathermap.js index 5885dae3..16f8c7d6 100644 --- a/js/weathermap.js +++ b/js/weathermap.js @@ -82,7 +82,11 @@ var waitForFinalEvent = (function () { // Optional pulsing glow for links at or above 80% utilisation. (function() { var maskSequence = 0; + var activeMaps = new Map(); function initTrafficGlow() { + activeMaps.forEach(function(cleanup, holder) { + if (!holder.isConnected) cleanup(); + }); document.querySelectorAll('.weathermapimage[data-wm-traffic]').forEach(function(holder) { if (holder.dataset.wmAnimationReady) return; var image = holder.querySelector('img[usemap]'), data; @@ -95,6 +99,7 @@ var waitForFinalEvent = (function () { var reduced = window.matchMedia('(prefers-reduced-motion: reduce)'), svg; function stop() { if (svg) { svg.remove(); svg = null; } } function cleanup() { + activeMaps.delete(holder); if (observer) observer.disconnect(); image.removeEventListener('load', align); window.removeEventListener('resize', align); @@ -188,6 +193,7 @@ var waitForFinalEvent = (function () { surface.appendChild(svg); align(); if (document.hidden || reduced.matches) svg.classList.add('wm-glow-paused'); } + activeMaps.set(holder, cleanup); toggle(); reduced.addEventListener('change', motionChanged); image.addEventListener('load', align); diff --git a/lib/editor.colours.php b/lib/editor.colours.php index cac3d19e..22cbc7dc 100644 --- a/lib/editor.colours.php +++ b/lib/editor.colours.php @@ -53,8 +53,8 @@ function wm_asset_save_colours($map, $data) { $scales = []; foreach ($rows as $row) { $name = $row['scale'] ?? ''; - if (!is_string($name) || !preg_match('/^[A-Za-z0-9_-]{1,64}$/D', $name)) throw new InvalidArgumentException(__('Invalid scale name.', 'weathermap')); - foreach (['bottom', 'top'] as $key) if (!isset($row[$key]) || !is_numeric($row[$key]) || !is_finite((float)$row[$key]) || $row[$key] < 0 || $row[$key] > 1e15) throw new InvalidArgumentException(__('Invalid scale range.', 'weathermap')); + if (!is_string($name) || !preg_match('/^[A-Za-z][A-Za-z0-9_]{0,63}$/D', $name)) throw new InvalidArgumentException(__('Invalid scale name.', 'weathermap')); + foreach (['bottom', 'top'] as $key) if (!isset($row[$key]) || !is_numeric($row[$key]) || !is_finite((float)$row[$key]) || abs((float)$row[$key]) > 1e15) throw new InvalidArgumentException(__('Invalid scale range.', 'weathermap')); if ($row['bottom'] > $row['top']) throw new InvalidArgumentException(__('Range start must be no greater than its end.', 'weathermap')); $rgb = wm_asset_rgb($row['colour'] ?? null); $tag = $row['tag'] ?? ''; @@ -72,8 +72,10 @@ function wm_asset_save_colours($map, $data) { foreach ($map->colours as $name => $colours) foreach ($colours as $key => $row) if (!empty($row['special'])) $scales[$name][$key] = $row; // Keep scales referenced by map objects rather than silently replacing them with defaults. foreach (array_merge(array_values($map->nodes), array_values($map->links)) as $object) { - $name = $object->usescale; - if ($name !== 'none' && isset($map->colours[$name]) && empty(array_filter($scales[$name] ?? [], function($r) { return empty($r['special']); }))) throw new InvalidArgumentException(__('A map object still uses scale %s.', $name, 'weathermap')); + foreach (['usescale', 'useiconscale'] as $field) { + $name = $object->$field ?? 'none'; + if ($name !== 'none' && isset($map->colours[$name]) && empty(array_filter($scales[$name] ?? [], function($r) { return empty($r['special']); }))) throw new InvalidArgumentException(__('A map object still uses scale %s.', $name, 'weathermap')); + } } $scales['DEFAULT']['BG']['red1'] = $background[0]; $scales['DEFAULT']['BG']['green1'] = $background[1]; diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index 440f8d9c..23da62fd 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Cacti 1.7\n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-12 00:49+1030\n" +"POT-Creation-Date: 2026-10-12 01:26+1030\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers \n" "Language-Team: Cacti Developers \n" diff --git a/tests/Browser/TrafficGlowTest.cjs b/tests/Browser/TrafficGlowTest.cjs index dd9e016f..66e5773f 100644 --- a/tests/Browser/TrafficGlowTest.cjs +++ b/tests/Browser/TrafficGlowTest.cjs @@ -21,6 +21,6 @@ const {JSDOM}=require('jsdom'),jquery=require('jquery'),fs=require('node:fs'),pa assert.equal(disabled.querySelector('svg'),null);assert.equal(quiet.querySelector('svg'),null); $(w.document).trigger('ajaxComplete');assert.equal(active.querySelectorAll('svg').length,1);assert.equal(observers.length,1); media.matches=true;media.listener();assert.ok(active.querySelector('svg').classList.contains('wm-glow-paused')); - active.remove();observers[0].fn();assert.ok(observers[0].disconnected);assert.equal(media.listener,null); + active.remove();$(w.document).trigger('ajaxComplete');assert.ok(observers[0].disconnected);assert.equal(media.listener,null); console.log('Traffic glow colour, masking, reduced motion and cleanup PASS');w.close(); })().catch(e=>{console.error(e);process.exitCode=1}); diff --git a/tests/Support/EditorAssetsRegression.php b/tests/Support/EditorAssetsRegression.php index 42a023d9..0a2d915f 100644 --- a/tests/Support/EditorAssetsRegression.php +++ b/tests/Support/EditorAssetsRegression.php @@ -66,6 +66,20 @@ function assets_assert($value, $message) { if (!$value) throw new RuntimeExcepti $missing['rows'] = array_values(array_filter($data['rows'], function($row) { return $row['scale'] !== 'custom'; })); try { wm_asset_save_colours($map, $missing); throw new RuntimeException('accepted removal of same-name node scale'); } catch (InvalidArgumentException $expected) {} unset($map->nodes['custom']); + $map->nodes['A']->useiconscale = 'custom'; + try { wm_asset_save_colours($map, $missing); throw new RuntimeException('accepted removal of icon scale'); } catch (InvalidArgumentException $expected) {} + $map->nodes['A']->useiconscale = 'none'; + foreach (['1_hour', 'one-hour', '_hour'] as $badName) { + $invalid = $data; $invalid['rows'][1]['scale'] = $badName; + try { wm_asset_save_colours($map, $invalid); throw new RuntimeException('accepted parser-incompatible scale name'); } catch (InvalidArgumentException $expected) {} + } + $negative = $data; + $negative['rows'][] = ['scale' => 'Temperature_1', 'bottom' => -20, 'top' => -1, 'colour' => '#0000ff', 'end' => '', 'tag' => 'Cold']; + wm_asset_save_colours($map, $negative); + $map->WriteConfig($file); + $negativeMap = new WeatherMap; $negativeMap->ReadConfig($file); + assets_assert($negativeMap->colours['Temperature_1']['-20_-1']['bottom'] === -20.0, 'negative custom scale survives engine reload'); + wm_asset_save_colours($map, $data); $before = $map->colours; foreach (['bad range', 'bad colour', 'config injection'] as $bad) { $invalid = $data; From 6122db9f0c7c4987bddd1193c9e71f10c4573d41 Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:08:22 +1030 Subject: [PATCH 05/43] Normalize scale ranges and render traffic glow in Live View --- lib/WeatherMap.animation.php | 8 ++++- lib/WeatherMap.class.php | 4 +-- lib/editor.actions.php | 8 +++++ lib/editor.colours.php | 36 ++++++++++++++++++-- locales/po/cacti.pot | 2 +- tests/Support/EditorAssetsRegression.php | 7 ++++ tests/Support/TrafficAnimationRegression.php | 8 +++++ weathermap-cacti-plugin.php | 7 ++-- 8 files changed, 68 insertions(+), 12 deletions(-) diff --git a/lib/WeatherMap.animation.php b/lib/WeatherMap.animation.php index f58e5b7c..23a3d472 100644 --- a/lib/WeatherMap.animation.php +++ b/lib/WeatherMap.animation.php @@ -22,7 +22,13 @@ +-------------------------------------------------------------------------+ */ -/** Browser-only high-traffic glow uses the rendered arrow outlines and colours. */ +/** + * Build browser glow metadata from rendered link geometry and colours. + * + * @param WeatherMap $map The rendered map supplying traffic and occlusion geometry. + * + * @return array{enabled: bool, width: int, height: int, regions: array, occlusions: array} Viewer metadata. + */ function wm_traffic_animation_data($map) { $empty = ['enabled' => $map->get_hint('traffic_pulse', '1') === '1', 'width' => $map->width, 'height' => $map->height, 'regions' => [], 'occlusions' => []]; if (!$empty['enabled']) return $empty; diff --git a/lib/WeatherMap.class.php b/lib/WeatherMap.class.php index 52f7ccfb..37af5f04 100644 --- a/lib/WeatherMap.class.php +++ b/lib/WeatherMap.class.php @@ -4547,7 +4547,7 @@ function MakeHTML($imagemapname = 'weathermap_imap', $standalone = false) { if ($this->imageuri != '') { $html .= sprintf( "\t\t\t
imageuri, + htmlspecialchars($this->imageuri, ENT_QUOTES, 'UTF-8'), $this->width, $this->height, $imagemapname @@ -4557,7 +4557,7 @@ function MakeHTML($imagemapname = 'weathermap_imap', $standalone = false) { } else { $html .= sprintf( "\t\t\t
imagefile, + htmlspecialchars($this->imagefile, ENT_QUOTES, 'UTF-8'), $this->width, $this->height, $imagemapname diff --git a/lib/editor.actions.php b/lib/editor.actions.php index 868c6036..df45edb8 100644 --- a/lib/editor.actions.php +++ b/lib/editor.actions.php @@ -1145,6 +1145,14 @@ function getImageURL($mapname, $selected) { return $imageurl; } +/** + * Read or save editor colour settings and emit a JSON response. + * + * @param string $mapfile The validated map configuration path. + * @param string $action The requested colour operation. + * + * @return void Errors are returned as HTTP 400 JSON responses. + */ function wm_editor_colours($mapfile, $action) { header('Content-Type: application/json'); try { diff --git a/lib/editor.colours.php b/lib/editor.colours.php index 22cbc7dc..684a0af9 100644 --- a/lib/editor.colours.php +++ b/lib/editor.colours.php @@ -22,15 +22,35 @@ +-------------------------------------------------------------------------+ */ -/** Local editor colour and image management. */ +/** + * Validate a colour string and convert it to RGB; malformed or non-string input is rejected. + * + * @param mixed $value A hexadecimal colour or the special value none. + * + * @return int[] The RGB components, or -1 components for none. + */ function wm_asset_rgb($value) { if ($value === 'none') return [-1, -1, -1]; if (!is_string($value) || !preg_match('/^#[0-9a-f]{6}$/iD', $value)) throw new InvalidArgumentException(__('Choose a valid colour.', 'weathermap')); return [hexdec(substr($value, 1, 2)), hexdec(substr($value, 3, 2)), hexdec(substr($value, 5, 2))]; } +/** + * Convert RGB components to a hexadecimal colour or none. + * + * @param int[] $rgb RGB components, with a negative red component representing none. + * + * @return string The colour accepted by the editor. + */ function wm_asset_hex($rgb) { return $rgb[0] < 0 ? 'none' : sprintf('#%02x%02x%02x', ...$rgb); } +/** + * Read the editable background, outline, pulse and scale settings. + * + * @param WeatherMap $map The map to inspect. + * + * @return array The editable settings and scale rows. + */ function wm_asset_colour_data($map) { $rows = []; foreach ($map->colours as $scale => $colours) foreach ($colours as $row) { @@ -43,6 +63,14 @@ function wm_asset_colour_data($map) { return ['background' => wm_asset_hex([$bg['red1'], $bg['green1'], $bg['blue1']]), 'outline' => wm_asset_hex($map->links['DEFAULT']->outlinecolour), 'pulse' => $map->get_hint('traffic_pulse', '1') === '1', 'rows' => $rows]; } +/** + * Validate all submitted settings before applying them to the map. + * + * @param WeatherMap $map The map receiving the validated settings. + * @param array $data The submitted settings; malformed values are rejected. + * + * @return void Invalid settings raise InvalidArgumentException. + */ function wm_asset_save_colours($map, $data) { if (array_key_exists('pulse', $data) && !is_bool($data['pulse'])) throw new InvalidArgumentException(__('Invalid pulse setting.', 'weathermap')); $background = array_key_exists('background', $data) ? wm_asset_rgb($data['background']) : [$map->colours['DEFAULT']['BG']['red1'], $map->colours['DEFAULT']['BG']['green1'], $map->colours['DEFAULT']['BG']['blue1']]; @@ -59,9 +87,11 @@ function wm_asset_save_colours($map, $data) { $rgb = wm_asset_rgb($row['colour'] ?? null); $tag = $row['tag'] ?? ''; if (!is_string($tag) || strlen($tag) > 200 || preg_match('/[\x00-\x1f\x7f]/', $tag)) throw new InvalidArgumentException(__('Invalid scale label.', 'weathermap')); - $key = $row['bottom'] . '_' . $row['top']; + $bottom = round((float)$row['bottom'], 6); + $top = round((float)$row['top'], 6); + $key = sprintf('%.6F_%.6F', $bottom == 0 ? 0 : $bottom, $top == 0 ? 0 : $top); if (isset($scales[$name][$key])) throw new InvalidArgumentException(__('Duplicate range in a scale.', 'weathermap')); - $entry = ['key' => $key, 'bottom' => (float)$row['bottom'], 'top' => (float)$row['top'], 'special' => 0, 'red1' => $rgb[0], 'green1' => $rgb[1], 'blue1' => $rgb[2], 'tag' => $tag]; + $entry = ['key' => $key, 'bottom' => $bottom, 'top' => $top, 'special' => 0, 'red1' => $rgb[0], 'green1' => $rgb[1], 'blue1' => $rgb[2], 'tag' => $tag]; if (!empty($row['end'])) { $end = wm_asset_rgb($row['end']); if ($rgb[0] < 0 || $end[0] < 0) throw new InvalidArgumentException(__('Gradients need two colours.', 'weathermap')); diff --git a/locales/po/cacti.pot b/locales/po/cacti.pot index 23da62fd..fb062c9f 100644 --- a/locales/po/cacti.pot +++ b/locales/po/cacti.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Cacti 1.7\n" "Report-Msgid-Bugs-To: developers@cacti.net\n" -"POT-Creation-Date: 2026-10-12 01:26+1030\n" +"POT-Creation-Date: 2026-10-12 09:02+1030\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Cacti Developers \n" "Language-Team: Cacti Developers \n" diff --git a/tests/Support/EditorAssetsRegression.php b/tests/Support/EditorAssetsRegression.php index 0a2d915f..dd1294c6 100644 --- a/tests/Support/EditorAssetsRegression.php +++ b/tests/Support/EditorAssetsRegression.php @@ -81,6 +81,13 @@ function assets_assert($value, $message) { if (!$value) throw new RuntimeExcepti assets_assert($negativeMap->colours['Temperature_1']['-20_-1']['bottom'] === -20.0, 'negative custom scale survives engine reload'); wm_asset_save_colours($map, $data); $before = $map->colours; + foreach ([[0, 100, '0.0', '100.0'], [0, 100, '0e0', '1e2'], [0, 100, '-0.0', 100.0000001], [-20, -1, '-20.0000001', '-1.0000001']] as $range) { + $duplicate = $data; + $duplicate['rows'][] = ['scale' => 'Duplicates', 'bottom' => $range[0], 'top' => $range[1], 'colour' => '#ff0000']; + $duplicate['rows'][] = ['scale' => 'Duplicates', 'bottom' => $range[2], 'top' => $range[3], 'colour' => '#0000ff']; + try { wm_asset_save_colours($map, $duplicate); throw new RuntimeException('accepted equivalent serialized range'); } catch (InvalidArgumentException $expected) {} + assets_assert($map->colours === $before, 'duplicate range validation leaves map unchanged'); + } foreach (['bad range', 'bad colour', 'config injection'] as $bad) { $invalid = $data; if ($bad === 'bad range') $invalid['rows'][0]['top'] = -1; diff --git a/tests/Support/TrafficAnimationRegression.php b/tests/Support/TrafficAnimationRegression.php index 8c8b940e..d7ab4938 100644 --- a/tests/Support/TrafficAnimationRegression.php +++ b/tests/Support/TrafficAnimationRegression.php @@ -48,6 +48,14 @@ function animation_assert($condition, $message) { if (!$condition) throw new Run $shape = array_values(array_filter($map->imap->shapes, fn($s) => $s->name === 'LINK:L' . $link->id . ':' . IN))[0]; animation_assert(count($data['regions'][0]['points']) === count($shape->points), 'full rendered curved polygon retained'); animation_assert(str_contains($map->MakeHTML(), 'data-wm-traffic='), 'viewer HTML contains safe metadata'); + $map->imageuri = '?action=liveviewimage&id=123'; + $live = new DOMDocument; + $live->loadHTML($map->MakeHTML('weathermap_live_imap')); + $liveImage = $live->getElementsByTagName('img')->item(0); + $liveHolder = $liveImage->parentNode->parentNode; + $liveData = json_decode($liveHolder->getAttribute('data-wm-traffic'), true); + animation_assert($liveImage->getAttribute('src') === '?action=liveviewimage&id=123' && $liveImage->getAttribute('usemap') === '#weathermap_live_imap', 'live-view endpoint and imagemap preserved'); + animation_assert(count($liveData['regions']) === 1 && $liveImage->getAttribute('width') === '400' && $liveImage->getAttribute('height') === '300', 'live view carries pulse metadata at logical dimensions'); foreach ([[255,0,0], [140,0,255], [0,192,255], [35,120,80], [255,180,0]] as $rgb) { $link->colours[IN] = new Colour(...$rgb); $expected = sprintf('#%02x%02x%02x', ...$rgb); diff --git a/weathermap-cacti-plugin.php b/weathermap-cacti-plugin.php index e905e0ee..f6029119 100644 --- a/weathermap-cacti-plugin.php +++ b/weathermap-cacti-plugin.php @@ -203,11 +203,8 @@ print ''; - print "\n"; - print $map->imap->subHTML('LEGEND:'); - print $map->imap->subHTML('TIMESTAMP'); - print $map->imap->subHTML('NODE:'); - print $map->imap->subHTML('LINK:'); + $map->imageuri = '?action=liveviewimage&id=' . rawurlencode((string)$id); + print $map->MakeHTML('weathermap_live_imap'); chdir($orig_cwd); From cdb23644e0b7e74c06af85f3edc1380a057ad59e Mon Sep 17 00:00:00 2001 From: alcatron <27321443+alcatron@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:09:53 +1030 Subject: [PATCH 06/43] Validate serialized scales and exercise the colour editor --- js/editor.js | 2 +- lib/editor.colours.php | 13 +++++++++---- locales/po/cacti.pot | 2 +- tests/Browser/EditorColoursTest.cjs | 24 ++++++++++++++++++++++++ tests/Support/EditorAssetsRegression.php | 8 +++++++- 5 files changed, 42 insertions(+), 7 deletions(-) create mode 100644 tests/Browser/EditorColoursTest.cjs diff --git a/js/editor.js b/js/editor.js index b9008d4f..cefbe918 100644 --- a/js/editor.js +++ b/js/editor.js @@ -1706,7 +1706,7 @@ function wmAssetScaleRow(row) { $('').append($('').attr('type', key === 'scale' ? 'text' : 'number').attr('step', 'any').addClass('wm-range-' + key).val(row[key])).appendTo(tr); }); $('').append(wmAssetColour(row.colour, false)).appendTo(tr); - $('').append(wmAssetColour(row.end || '', true)).appendTo(tr); + $('').append(wmAssetColour(row.end || '', true, false, true)).appendTo(tr); $('').append($('').val(row.tag || '')).appendTo(tr); $('').append($('
',{runScripts:'outside-only',pretendToBeVisual:true}); +const w=dom.window,$=jquery(w);w.$=w.jQuery=$;w.editor_url='/editor';w.csrfMagicToken='fixture';$.fn.dialog=function(){return this;};$.fn.selectmenu=function(command){return command==='instance'?undefined:this;}; +w.wmEditorText={palette:{Black:'Black',White:'White',Blue:'Bleu',Red:'Red',Green:'Green',Purple:'Purple',Orange:'Orange',Grey:'Grey',None:'None'},colourText0:'No gradient',colourText1:'Custom colour',colourText2:'Remove',colourText3:'No ranges',colourText4:'Save failed',colourText7:'Remove range',colourText8:'Custom %s'}; +const source=fs.readFileSync(path.join(__dirname,'../../js/editor.js'),'utf8');w.eval(source.slice(source.indexOf('var wmAssetPalette'))); +Object.keys(w.wmTrafficPresets).concat('custom').forEach(key=>$('#wm-traffic-preset').append($('