feat: store zone coordinates as percentages for resolution independence

Convert zone coordinates from absolute pixel values to percentages
(0.00-100.00) so zones automatically adapt when monitor resolution
changes. This eliminates the need to manually reconfigure zones after
resolution adjustments.

Changes:
- Add DB migration (zm_update-1.37.81.sql) to convert existing pixel
  coords to percentages, recalculate area, and update Units default
- Add Zone::ParsePercentagePolygon() in C++ to parse percentage coords
  and convert to pixels at runtime using monitor dimensions
- Backwards compat: C++ Zone::Load() checks Units column and uses old
  pixel parser for legacy 'Pixels' zones
- Update PHP coordsToPoints/mapCoords/getPolyArea for float coords,
  replace scanline area algorithm with shoelace formula
- Update JS zone editor to work in percentage coordinate space with
  SVG viewBox "0 0 100 100" and non-scaling-stroke for consistent
  line thickness
- Position zone SVG overlay inside imageFeed container via JS to align
  with image only (not status bar)
- Support array of zone IDs in Monitor::getStreamHTML zones option
- Update monitor resize handler: percentage coords don't need rescaling,
  only threshold pixel counts are adjusted
- Add 8 Catch2 unit tests for ParsePercentagePolygon

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Isaac Connor
2026-02-10 17:18:00 -05:00
parent 6ce1b08dc7
commit 17450d122d
22 changed files with 453 additions and 281 deletions

View File

@@ -883,7 +883,7 @@ CREATE TABLE `ZonePresets` (
`Id` int(10) unsigned NOT NULL auto_increment,
`Name` varchar(64) NOT NULL default '',
`Type` enum('Active','Inclusive','Exclusive','Preclusive','Inactive','Privacy') NOT NULL default 'Active',
`Units` enum('Pixels','Percent') NOT NULL default 'Pixels',
`Units` enum('Pixels','Percent') NOT NULL default 'Percent',
`CheckMethod` enum('AlarmedPixels','FilteredPixels','Blobs') NOT NULL default 'Blobs',
`MinPixelThreshold` smallint(5) unsigned default NULL,
`MaxPixelThreshold` smallint(5) unsigned default NULL,
@@ -913,7 +913,7 @@ CREATE TABLE `Zones` (
FOREIGN KEY (`MonitorId`) REFERENCES `Monitors` (`Id`) ON DELETE CASCADE,
`Name` varchar(64) NOT NULL default '',
`Type` enum('Active','Inclusive','Exclusive','Preclusive','Inactive','Privacy') NOT NULL default 'Active',
`Units` enum('Pixels','Percent') NOT NULL default 'Pixels',
`Units` enum('Pixels','Percent') NOT NULL default 'Percent',
`NumCoords` tinyint(3) unsigned NOT NULL default '0',
`Coords` tinytext NOT NULL,
`Area` int(10) unsigned NOT NULL default '0',

110
db/zm_update-1.37.81.sql Normal file
View File

@@ -0,0 +1,110 @@
--
-- This updates a 1.37.80 database to 1.37.81
--
-- Convert Zone Coords from pixel values to percentage values (0.00-100.00)
-- so that zones are resolution-independent.
--
DELIMITER //
DROP PROCEDURE IF EXISTS `zm_update_zone_coords_to_percent` //
CREATE PROCEDURE `zm_update_zone_coords_to_percent`()
BEGIN
DECLARE done INT DEFAULT FALSE;
DECLARE v_zone_id INT;
DECLARE v_coords TINYTEXT;
DECLARE v_mon_width INT;
DECLARE v_mon_height INT;
DECLARE v_new_coords TEXT DEFAULT '';
DECLARE v_pair TEXT;
DECLARE v_x_str TEXT;
DECLARE v_y_str TEXT;
DECLARE v_x_pct TEXT;
DECLARE v_y_pct TEXT;
DECLARE v_remaining TEXT;
DECLARE v_space_pos INT;
DECLARE cur CURSOR FOR
SELECT z.Id, z.Coords, m.Width, m.Height
FROM Zones z
JOIN Monitors m ON z.MonitorId = m.Id
WHERE m.Width > 0 AND m.Height > 0;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;
OPEN cur;
read_loop: LOOP
FETCH cur INTO v_zone_id, v_coords, v_mon_width, v_mon_height;
IF done THEN
LEAVE read_loop;
END IF;
-- Skip if coords already look like percentages (contain a decimal point)
IF v_coords LIKE '%.%' THEN
ITERATE read_loop;
END IF;
SET v_new_coords = '';
SET v_remaining = TRIM(v_coords);
-- Parse each space-separated x,y pair
coord_loop: LOOP
IF v_remaining = '' OR v_remaining IS NULL THEN
LEAVE coord_loop;
END IF;
SET v_space_pos = LOCATE(' ', v_remaining);
IF v_space_pos > 0 THEN
SET v_pair = LEFT(v_remaining, v_space_pos - 1);
SET v_remaining = TRIM(SUBSTRING(v_remaining, v_space_pos + 1));
ELSE
SET v_pair = v_remaining;
SET v_remaining = '';
END IF;
-- Skip empty pairs (from double spaces, trailing commas etc)
IF v_pair = '' OR v_pair = ',' THEN
ITERATE coord_loop;
END IF;
-- Split on comma
SET v_x_str = SUBSTRING_INDEX(v_pair, ',', 1);
SET v_y_str = SUBSTRING_INDEX(v_pair, ',', -1);
-- Convert to percentage with 2 decimal places
-- Use CAST to DECIMAL which always uses '.' as decimal separator (locale-independent)
SET v_x_pct = CAST(ROUND(CAST(v_x_str AS DECIMAL(10,2)) / v_mon_width * 100, 2) AS DECIMAL(10,2));
SET v_y_pct = CAST(ROUND(CAST(v_y_str AS DECIMAL(10,2)) / v_mon_height * 100, 2) AS DECIMAL(10,2));
IF v_new_coords != '' THEN
SET v_new_coords = CONCAT(v_new_coords, ' ');
END IF;
SET v_new_coords = CONCAT(v_new_coords, v_x_pct, ',', v_y_pct);
END LOOP coord_loop;
IF v_new_coords != '' THEN
UPDATE Zones SET Coords = v_new_coords WHERE Id = v_zone_id;
END IF;
END LOOP read_loop;
CLOSE cur;
END //
DELIMITER ;
CALL zm_update_zone_coords_to_percent();
DROP PROCEDURE IF EXISTS `zm_update_zone_coords_to_percent`;
-- Recalculate Area from pixel-space to percentage-space (100x100 = 10000 for full frame)
UPDATE Zones z
JOIN Monitors m ON z.MonitorId = m.Id
SET z.Area = ROUND(z.Area * 10000.0 / (m.Width * m.Height))
WHERE m.Width > 0 AND m.Height > 0 AND z.Area > 0;
-- Update Units to Percent for all zones, and set as new default
UPDATE Zones SET Units = 'Percent' WHERE Units = 'Pixels';
ALTER TABLE Zones ALTER Units SET DEFAULT 'Percent';

View File

@@ -23,6 +23,8 @@
#include "zm_fifo_debug.h"
#include "zm_monitor.h"
#include <cstdlib>
void Zone::Setup(
ZoneType p_type,
const Polygon &p_polygon,
@@ -792,6 +794,47 @@ bool Zone::ParsePolygonString(const char *poly_string, Polygon &polygon) {
return !vertices.empty();
} // end bool Zone::ParsePolygonString(const char *poly_string, Polygon &polygon)
bool Zone::ParsePercentagePolygon(const char *poly_string, unsigned int width, unsigned int height, Polygon &polygon) {
double mon_w = static_cast<double>(width);
double mon_h = static_cast<double>(height);
std::vector<Vector2> vertices;
const char *str = poly_string;
while (*str != '\0') {
const char *cp = strchr(str, ',');
if (!cp) {
Error("Bogus coordinate %s found in polygon string", str);
break;
}
double pct_x = strtod(str, nullptr);
double pct_y = strtod(cp + 1, nullptr);
int32 px_x = static_cast<int32>(std::lround(pct_x * mon_w / 100.0));
int32 px_y = static_cast<int32>(std::lround(pct_y * mon_h / 100.0));
// Clamp to monitor bounds
px_x = std::clamp(px_x, static_cast<int32>(0), static_cast<int32>(width));
px_y = std::clamp(px_y, static_cast<int32>(0), static_cast<int32>(height));
Debug(3, "Percentage coord %.2f,%.2f -> pixel %d,%d", pct_x, pct_y, px_x, px_y);
vertices.emplace_back(px_x, px_y);
const char *ws = strchr(cp + 2, ' ');
if (ws) {
str = ws + 1;
} else {
break;
}
}
if (vertices.size() > 2) {
polygon = Polygon(vertices);
return true;
}
Error("Not enough coordinates to form a polygon from '%s'", poly_string);
return false;
} // end bool Zone::ParsePercentagePolygon
bool Zone::ParseZoneString(const char *zone_string, unsigned int &zone_id, int &colour, Polygon &polygon) {
Debug(3, "Parsing zone string '%s'", zone_string);
@@ -891,48 +934,22 @@ std::vector<Zone> Zone::Load(const std::shared_ptr<Monitor> &monitor) {
/* HTML colour code is actually BGR in memory, we want RGB */
AlarmRGB = rgb_convert(AlarmRGB, ZM_SUBPIX_ORDER_BGR);
Debug(5, "Parsing polygon %s", Coords);
Debug(5, "Parsing polygon %s (Units=%s)", Coords, Units);
Polygon polygon;
if ( !ParsePolygonString(Coords, polygon) ) {
Error("Unable to parse polygon string '%s' for zone %d/%s for monitor %s, ignoring", Coords, Id, Name, monitor->Name());
continue;
}
if (polygon.Extent().Lo().x_ < 0
||
polygon.Extent().Hi().x_ > static_cast<int32>(monitor->Width())
||
polygon.Extent().Lo().y_ < 0
||
polygon.Extent().Hi().y_ > static_cast<int32>(monitor->Height())) {
Error("Zone %d/%s for monitor %s extends outside of image dimensions, (%d,%d), (%d,%d) != (%d,%d), fixing",
Id,
Name,
monitor->Name(),
polygon.Extent().Lo().x_,
polygon.Extent().Lo().y_,
polygon.Extent().Hi().x_,
polygon.Extent().Hi().y_,
monitor->Width(),
monitor->Height());
auto n_coords = polygon.GetVertices().size();
polygon.Clip(Box(
{0, 0},
{static_cast<int32>(monitor->Width()), static_cast<int32>(monitor->Height())}
));
if (polygon.GetVertices().size() != n_coords) {
Error("Cropping altered the number of vertices! From %zu to %zu", n_coords, polygon.GetVertices().size());
if (!strcmp(Units, "Pixels")) {
// Legacy pixel-based coordinates: parse as integer pixel values
if (!ParsePolygonString(Coords, polygon)) {
Error("Unable to parse polygon string '%s' for zone %d/%s for monitor %s, ignoring",
Coords, Id, Name, monitor->Name());
continue;
}
} else {
// Percentage-based coordinates (default): convert to pixels using monitor dimensions
if (!ParsePercentagePolygon(Coords, monitor->Width(), monitor->Height(), polygon)) {
Error("Unable to parse polygon string '%s' for zone %d/%s for monitor %s, ignoring",
Coords, Id, Name, monitor->Name());
continue;
}
}
if ( false && !strcmp( Units, "Percent" ) ) {
MinAlarmPixels = (MinAlarmPixels*polygon.Area())/100;
MaxAlarmPixels = (MaxAlarmPixels*polygon.Area())/100;
MinFilterPixels = (MinFilterPixels*polygon.Area())/100;
MaxFilterPixels = (MaxFilterPixels*polygon.Area())/100;
MinBlobPixels = (MinBlobPixels*polygon.Area())/100;
MaxBlobPixels = (MaxBlobPixels*polygon.Area())/100;
}
if (atoi(dbrow[2]) == Zone::INACTIVE) {

View File

@@ -211,6 +211,7 @@ class Zone {
std::string DumpSettings(bool verbose) const;
static bool ParsePolygonString( const char *polygon_string, Polygon &polygon );
static bool ParsePercentagePolygon(const char *poly_string, unsigned int width, unsigned int height, Polygon &polygon);
static bool ParseZoneString( const char *zone_string, unsigned int &zone_id, int &colour, Polygon &polygon );
static std::vector<Zone> Load(const std::shared_ptr<Monitor> &monitor);
//=================================================

View File

@@ -19,7 +19,8 @@ set(TEST_SOURCES
zm_onvif_renewal.cpp
zm_poly.cpp
zm_utils.cpp
zm_vector2.cpp)
zm_vector2.cpp
zm_zone.cpp)
add_executable(tests main.cpp ${TEST_SOURCES})

120
tests/zm_zone.cpp Normal file
View File

@@ -0,0 +1,120 @@
/*
* This file is part of the ZoneMinder Project. See AUTHORS file for Copyright information
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the
* Free Software Foundation; either version 2 of the License, or (at your
* option) any later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include "zm_catch2.h"
#include "zm_zone.h"
TEST_CASE("Zone::ParsePercentagePolygon: full-frame zone at 1920x1080", "[Zone]") {
Polygon polygon;
bool result = Zone::ParsePercentagePolygon(
"0.00,0.00 100.00,0.00 100.00,100.00 0.00,100.00",
1920, 1080, polygon);
REQUIRE(result == true);
REQUIRE(polygon.GetVertices().size() == 4);
REQUIRE(polygon.GetVertices()[0] == Vector2(0, 0));
REQUIRE(polygon.GetVertices()[1] == Vector2(1920, 0));
REQUIRE(polygon.GetVertices()[2] == Vector2(1920, 1080));
REQUIRE(polygon.GetVertices()[3] == Vector2(0, 1080));
}
TEST_CASE("Zone::ParsePercentagePolygon: center 50% zone", "[Zone]") {
Polygon polygon;
bool result = Zone::ParsePercentagePolygon(
"25.00,25.00 75.00,25.00 75.00,75.00 25.00,75.00",
1920, 1080, polygon);
REQUIRE(result == true);
REQUIRE(polygon.GetVertices().size() == 4);
REQUIRE(polygon.GetVertices()[0] == Vector2(480, 270));
REQUIRE(polygon.GetVertices()[1] == Vector2(1440, 270));
REQUIRE(polygon.GetVertices()[2] == Vector2(1440, 810));
REQUIRE(polygon.GetVertices()[3] == Vector2(480, 810));
}
TEST_CASE("Zone::ParsePercentagePolygon: fractional percentages", "[Zone]") {
Polygon polygon;
bool result = Zone::ParsePercentagePolygon(
"50.25,75.50 60.00,75.50 60.00,85.00 50.25,85.00",
1920, 1080, polygon);
REQUIRE(result == true);
REQUIRE(polygon.GetVertices().size() == 4);
// 50.25% of 1920 = 964.8 -> 965
REQUIRE(polygon.GetVertices()[0].x_ == 965);
// 75.50% of 1080 = 815.4 -> 815
REQUIRE(polygon.GetVertices()[0].y_ == 815);
}
TEST_CASE("Zone::ParsePercentagePolygon: different resolution", "[Zone]") {
Polygon polygon;
bool result = Zone::ParsePercentagePolygon(
"0.00,0.00 100.00,0.00 100.00,100.00 0.00,100.00",
640, 480, polygon);
REQUIRE(result == true);
REQUIRE(polygon.GetVertices()[1] == Vector2(640, 0));
REQUIRE(polygon.GetVertices()[2] == Vector2(640, 480));
}
TEST_CASE("Zone::ParsePercentagePolygon: clamping beyond 100%", "[Zone]") {
Polygon polygon;
bool result = Zone::ParsePercentagePolygon(
"0.00,0.00 110.00,0.00 100.00,100.00 0.00,100.00",
1920, 1080, polygon);
REQUIRE(result == true);
// 110% should be clamped to monitor width
REQUIRE(polygon.GetVertices()[1].x_ == 1920);
}
TEST_CASE("Zone::ParsePercentagePolygon: triangle", "[Zone]") {
Polygon polygon;
bool result = Zone::ParsePercentagePolygon(
"50.00,10.00 90.00,90.00 10.00,90.00",
1000, 1000, polygon);
REQUIRE(result == true);
REQUIRE(polygon.GetVertices().size() == 3);
REQUIRE(polygon.GetVertices()[0] == Vector2(500, 100));
REQUIRE(polygon.GetVertices()[1] == Vector2(900, 900));
REQUIRE(polygon.GetVertices()[2] == Vector2(100, 900));
}
TEST_CASE("Zone::ParsePercentagePolygon: too few points", "[Zone]") {
Polygon polygon;
bool result = Zone::ParsePercentagePolygon(
"0.00,0.00 100.00,0.00",
1920, 1080, polygon);
REQUIRE(result == false);
}
TEST_CASE("Zone::ParsePercentagePolygon: integer coords still work", "[Zone]") {
// strtod handles integers fine
Polygon polygon;
bool result = Zone::ParsePercentagePolygon(
"0,0 100,0 100,100 0,100",
1920, 1080, polygon);
REQUIRE(result == true);
REQUIRE(polygon.GetVertices()[0] == Vector2(0, 0));
REQUIRE(polygon.GetVertices()[1] == Vector2(1920, 0));
REQUIRE(polygon.GetVertices()[2] == Vector2(1920, 1080));
REQUIRE(polygon.GetVertices()[3] == Vector2(0, 1080));
}

View File

@@ -1208,10 +1208,24 @@ class Monitor extends ZM_Object {
}
if (isset($options['zones']) and $options['zones']) {
$html .= '<svg class="zones" id="zones'.$this->Id().'" viewBox="0 0 '.$this->ViewWidth().' '.$this->ViewHeight() .'" preserveAspectRatio="none">'.PHP_EOL;
foreach (Zone::find(array('MonitorId'=>$this->Id()), array('order'=>'Area DESC')) as $zone) {
$html .= $zone->svg_polygon();
} // end foreach zone
$html .= '<svg class="zones" id="zones'.$this->Id().'" viewBox="0 0 100 100" preserveAspectRatio="none">'.PHP_EOL;
if (is_array($options['zones'])) {
// Render specific zone IDs only
foreach ($options['zones'] as $zone_id) {
$zone = new Zone($zone_id);
if ($zone->Id() and $zone->MonitorId() == $this->Id()) {
$html .= $zone->svg_polygon();
}
}
} else {
// true: render all zones for this monitor
foreach (Zone::find(array('MonitorId'=>$this->Id()), array('order'=>'Area DESC')) as $zone) {
$html .= $zone->svg_polygon();
}
}
if (isset($options['zones_extra'])) {
$html .= $options['zones_extra'];
}
$html .= '
Sorry, your browser does not support inline SVG
</svg>

View File

@@ -12,7 +12,7 @@ class Zone extends ZM_Object {
'MonitorId' => null,
'Name' => '',
'Type' => 'Active',
'Units' => 'Pixels',
'Units' => 'Percent',
'NumCoords' => '4',
'Coords' => '',
'Area' => '0',

View File

@@ -181,27 +181,22 @@ if ($action == 'save') {
$zones = dbFetchAll('SELECT * FROM Zones WHERE MonitorId=?', NULL, array($mid));
// Zone coords are stored as percentages, so they don't need rescaling
// on resolution change. Only handle rotation (swap x/y) and rescale
// threshold pixel counts.
$newA = $newW * $newH;
$oldA = $oldMonitor->Width() * $oldMonitor->Height();
if ( ($newW == $oldMonitor->Height()) and ($newH == $oldMonitor->Width()) ) {
// Rotation: swap x,y percentage coords
foreach ( $zones as $zone ) {
$newZone = $zone;
# Rotation, no change to area etc just swap the coords
$newZone = $zone;
$points = coordsToPoints($zone['Coords']);
for ( $i = 0; $i < count($points); $i++ ) {
$x = $points[$i]['x'];
$points[$i]['x'] = $points[$i]['y'];
$points[$i]['y'] = $x;
if ( $points[$i]['x'] > ($newW-1) ) {
ZM\Warning("Correcting x {$points[$i]['x']} > $newW of zone {$newZone['Name']} as it extends outside the new dimensions");
$points[$i]['x'] = ($newW-1);
}
if ( $points[$i]['y'] > ($newH-1) ) {
ZM\Warning("Correcting y {$points[$i]['y']} $newH of zone {$newZone['Name']} as it extends outside the new dimensions");
$points[$i]['y'] = ($newH-1);
}
}
$newZone['Coords'] = pointsToCoords($points);
$changes = getFormChanges($zone, $newZone, $types);
@@ -210,33 +205,17 @@ if ($action == 'save') {
array($mid, $zone['Id']));
}
} # end foreach zone
} else {
$newA = $newW * $newH;
$oldA = $oldMonitor->Width() * $oldMonitor->Height();
} else if ($oldA > 0 && $newA != $oldA) {
// Non-rotation resize: coords stay the same (percentages),
// but rescale threshold pixel counts by area ratio
foreach ( $zones as $zone ) {
$newZone = $zone;
$points = coordsToPoints($zone['Coords']);
for ( $i = 0; $i < count($points); $i++ ) {
$points[$i]['x'] = intval(($points[$i]['x']*($newW-1))/($oldMonitor->Width()-1));
$points[$i]['y'] = intval(($points[$i]['y']*($newH-1))/($oldMonitor->Height()-1));
if ( $points[$i]['x'] > ($newW-1) ) {
ZM\Warning("Correcting x of zone {$newZone['Name']} as it extends outside the new dimensions");
$points[$i]['x'] = ($newW-1);
}
if ( $points[$i]['y'] > ($newH-1) ) {
ZM\Warning("Correcting y of zone {$newZone['Name']} as it extends outside the new dimensions");
$points[$i]['y'] = ($newH-1);
}
}
$newZone['Coords'] = pointsToCoords($points);
$newZone['Area'] = intval(round(($zone['Area']*$newA)/$oldA));
$newZone['MinAlarmPixels'] = intval(round(($newZone['MinAlarmPixels']*$newA)/$oldA));
$newZone['MaxAlarmPixels'] = intval(round(($newZone['MaxAlarmPixels']*$newA)/$oldA));
$newZone['MinFilterPixels'] = intval(round(($newZone['MinFilterPixels']*$newA)/$oldA));
$newZone['MaxFilterPixels'] = intval(round(($newZone['MaxFilterPixels']*$newA)/$oldA));
$newZone['MinBlobPixels'] = intval(round(($newZone['MinBlobPixels']*$newA)/$oldA));
$newZone['MaxBlobPixels'] = intval(round(($newZone['MaxBlobPixels']*$newA)/$oldA));
$newZone['MinAlarmPixels'] = intval(round(($zone['MinAlarmPixels']*$newA)/$oldA));
$newZone['MaxAlarmPixels'] = intval(round(($zone['MaxAlarmPixels']*$newA)/$oldA));
$newZone['MinFilterPixels'] = intval(round(($zone['MinFilterPixels']*$newA)/$oldA));
$newZone['MaxFilterPixels'] = intval(round(($zone['MaxFilterPixels']*$newA)/$oldA));
$newZone['MinBlobPixels'] = intval(round(($zone['MinBlobPixels']*$newA)/$oldA));
$newZone['MaxBlobPixels'] = intval(round(($zone['MaxBlobPixels']*$newA)/$oldA));
$changes = getFormChanges($zone, $newZone, $types);
@@ -261,26 +240,20 @@ if ($action == 'save') {
if ( $monitor->insert($changes) ) {
$mid = $monitor->Id();
// Adjust zone dimensions if monitor has rotation applied
// Zone coords are now stored as percentages (0-100)
$zoneWidth = $newMonitor['Width'];
$zoneHeight = $newMonitor['Height'];
if (isset($newMonitor['Orientation']) &&
($newMonitor['Orientation'] == 'ROTATE_90' || $newMonitor['Orientation'] == 'ROTATE_270')) {
// Swap dimensions for 90/270 degree rotations
$zoneWidth = $newMonitor['Height'];
$zoneHeight = $newMonitor['Width'];
}
$zoneArea = $zoneWidth * $zoneHeight;
$zone = new ZM\Zone();
if (!$zone->save(['MonitorId'=>$monitor->Id(), 'Name'=>'All', 'Coords'=>
sprintf( '%d,%d %d,%d %d,%d %d,%d', 0, 0,
$zoneWidth-1,
0,
$zoneWidth-1,
$zoneHeight-1,
0,
$zoneHeight-1),
'Area'=>$zoneArea,
if (!$zone->save(['MonitorId'=>$monitor->Id(), 'Name'=>'All',
'Units'=>'Percent',
'Coords'=>'0.00,0.00 100.00,0.00 100.00,100.00 0.00,100.00',
'Area'=>10000,
'MinAlarmPixels'=>intval(($zoneArea*.05)/100),
'MaxAlarmPixels'=>intval(($zoneArea*75)/100),
'MinFilterPixels'=>intval(($zoneArea*.05)/100),

View File

@@ -32,13 +32,15 @@ if ( !empty($_REQUEST['mid']) && canEdit('Monitors', $_REQUEST['mid']) ) {
}
if ( $_REQUEST['newZone']['Units'] == 'Percent' ) {
// Convert percentage thresholds to pixel counts using actual monitor pixel area
$pixelArea = $monitor->ViewWidth() * $monitor->ViewHeight();
foreach (array(
'MinAlarmPixels','MaxAlarmPixels',
'MinFilterPixels','MaxFilterPixels',
'MinBlobPixels','MaxBlobPixels'
) as $field ) {
if ( isset($_REQUEST['newZone'][$field]) and $_REQUEST['newZone'][$field] )
$_REQUEST['newZone'][$field] = intval(($_REQUEST['newZone'][$field]*$_REQUEST['newZone']['Area'])/100);
$_REQUEST['newZone'][$field] = intval(($_REQUEST['newZone'][$field]*$pixelArea)/100);
}
}

View File

@@ -1384,96 +1384,16 @@ function _CompareX($a, $b) {
}
function getPolyArea($points) {
global $debug;
$n_coords = count($points);
$global_edges = array();
for ( $j = 0, $i = $n_coords-1; $j < $n_coords; $i = $j++ ) {
$x1 = $points[$i]['x'];
$x2 = $points[$j]['x'];
$y1 = $points[$i]['y'];
$y2 = $points[$j]['y'];
//printf( "x1:%d,y1:%d x2:%d,y2:%d\n", x1, y1, x2, y2 );
if ( $y1 == $y2 )
continue;
$dx = $x2 - $x1;
$dy = $y2 - $y1;
$global_edges[] = array(
'min_y' => $y1<$y2?$y1:$y2,
'max_y' => ($y1<$y2?$y2:$y1)+1,
'min_x' => $y1<$y2?$x1:$x2,
'_1_m' => $dx/$dy,
);
}
usort($global_edges, '_CompareXY');
if ( $debug ) {
for ( $i = 0; $i < count($global_edges); $i++ ) {
printf('%d: min_y: %d, max_y:%d, min_x:%.2f, 1/m:%.2f<br>',
$i,
$global_edges[$i]['min_y'],
$global_edges[$i]['max_y'],
$global_edges[$i]['min_x'],
$global_edges[$i]['_1_m']);
}
}
// Shoelace formula - works correctly with both integer and float coordinates
$n = count($points);
$area = 0.0;
$active_edges = array();
$y = $global_edges[0]['min_y'];
do {
for ( $i = 0; $i < count($global_edges); $i++ ) {
if ( $global_edges[$i]['min_y'] == $y ) {
if ( $debug ) printf('Moving global edge<br>');
$active_edges[] = $global_edges[$i];
array_splice($global_edges, $i, 1);
$i--;
} else {
break;
}
}
usort($active_edges, '_CompareX');
if ( $debug ) {
for ( $i = 0; $i < count($active_edges); $i++ ) {
printf('%d - %d: min_y: %d, max_y:%d, min_x:%.2f, 1/m:%.2f<br>',
$y, $i,
$active_edges[$i]['min_y'],
$active_edges[$i]['max_y'],
$active_edges[$i]['min_x'],
$active_edges[$i]['_1_m']);
}
}
$last_x = 0;
$row_area = 0;
$parity = false;
for ( $i = 0; $i < count($active_edges); $i++ ) {
$x = intval(round($active_edges[$i]['min_x']));
if ( $parity ) {
$row_area += ($x - $last_x)+1;
$area += $row_area;
}
if ( $active_edges[$i]['max_y'] != $y )
$parity = !$parity;
$last_x = $x;
}
if ( $debug ) printf('%d: Area:%d<br>', $y, $row_area);
$y++;
for ( $i = 0; $i < count($active_edges); $i++ ) {
if ( $y >= $active_edges[$i]['max_y'] ) { // Or >= as per sheets
if ( $debug ) printf('Deleting active_edge<br>');
array_splice($active_edges, $i, 1);
$i--;
} else {
$active_edges[$i]['min_x'] += $active_edges[$i]['_1_m'];
}
}
} while ( count($global_edges) || count($active_edges) );
if ( $debug ) printf('Area:%d<br>', $area);
return $area;
for ($i = 0; $i < $n - 1; $i++) {
$area += ((float)$points[$i]['x'] * (float)$points[$i+1]['y']
- (float)$points[$i+1]['x'] * (float)$points[$i]['y']);
}
$area += ((float)$points[$n-1]['x'] * (float)$points[0]['y']
- (float)$points[0]['x'] * (float)$points[$n-1]['y']);
return round(abs($area) / 2.0);
}
function getPolyAreaOld($points) {
@@ -1502,7 +1422,7 @@ function getPolyAreaOld($points) {
}
function mapCoords($a) {
return $a['x'].','.$a['y'];
return number_format((float)$a['x'], 2, '.', '').','.number_format((float)$a['y'], 2, '.', '');
}
function pointsToCoords($points) {
@@ -1511,10 +1431,10 @@ function pointsToCoords($points) {
function coordsToPoints($coords) {
$points = array();
if ( preg_match_all('/(\d+,\d+)+/', $coords, $matches) ) {
if ( preg_match_all('/([\d.]+,[\d.]+)+/', $coords, $matches) ) {
for ( $i = 0; $i < count($matches[1]); $i++ ) {
if ( preg_match('/(\d+),(\d+)/', $matches[1][$i], $cmatches) ) {
$points[] = array('x'=>$cmatches[1], 'y'=>$cmatches[2]);
if ( preg_match('/([\d.]+),([\d.]+)/', $matches[1][$i], $cmatches) ) {
$points[] = array('x'=>(float)$cmatches[1], 'y'=>(float)$cmatches[2]);
} else {
echo('Bogus coordinates ('.$matches[$i].')');
return false;

View File

@@ -329,9 +329,7 @@ svg.zones {
left: 0;
background: none;
width: 100%;
/*
height: 100%;
*/
}
#videoobj {
width: 100%;

View File

@@ -43,12 +43,16 @@
#imageFrame .monitor {
width: 100%;
}
#imageFrame .imageFeed {
position: relative;
}
#imageFrame svg {
box-sizing: border-box;
position:absolute;
top: 3px;
left: 3px;
right: 3px;
box-sizing: border-box;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: none;
}
#imageFrame img {
@@ -117,9 +121,11 @@
.zones polygon {
fill-opacity: 0.25;
stroke-width: 0;
vector-effect: non-scaling-stroke;
}
.zones polygon.Editing {
stroke-width: 2px;
vector-effect: non-scaling-stroke;
}
.Active {
stroke: #ff0000;

View File

@@ -1,6 +1,7 @@
.zones polygon {
fill-opacity: 0.25;
stroke-width: 2px;
vector-effect: non-scaling-stroke;
}
.Active {
stroke: #ff0000;

View File

@@ -1,5 +1,6 @@
.zones polygon {
fill-opacity: 0.25;
vector-effect: non-scaling-stroke;
}
.Active {
stroke: #ff0000;

View File

@@ -1,6 +1,7 @@
.zones polygon {
fill-opacity: 0.25;
stroke-width: 2px;
vector-effect: non-scaling-stroke;
}
.Active {
stroke: #ff0000;

View File

@@ -1,6 +1,7 @@
.zones polygon {
fill-opacity: 0.25;
stroke-width: 2px;
vector-effect: non-scaling-stroke;
}
.Active {
stroke: #ff0000;

View File

@@ -379,7 +379,7 @@ if ($video_tag) {
<div class="progressBox" id="progressBox" title="" style="width: 0%;"></div>
<div id="indicator" style="display: none;"></div>
</div><!--progressBar-->
<svg class="zones" id="zones<?php echo $monitor->Id() ?>" style="display:<?php echo $showZones ? 'block' : 'none'; ?>" viewBox="0 0 <?php echo $monitor->ViewWidth().' '.$monitor->ViewHeight() ?>" preserveAspectRatio="none">
<svg class="zones" id="zones<?php echo $monitor->Id() ?>" style="display:<?php echo $showZones ? 'block' : 'none'; ?>" viewBox="0 0 100 100" preserveAspectRatio="none">
<?php
foreach (ZM\Zone::find(array('MonitorId'=>$monitor->Id()), array('order'=>'Area DESC')) as $zone) {
echo $zone->svg_polygon();

View File

@@ -210,25 +210,28 @@ function toPercent(field, maxValue) {
}
function applyZoneUnits() {
var area = zone.Area;
// zone.Area is in percentage-space (0-10000 for full frame)
// Threshold fields are stored as pixel counts in the DB
// Convert to pixel area for threshold display conversions
var pixelArea = Math.round(zone.Area / monitorArea * monitorPixelArea);
var form = document.zoneForm;
if ( form.elements['newZone[Units]'].value == 'Pixels' ) {
form.elements['newZone[Area]'].value = area;
toPixels(form.elements['newZone[MinAlarmPixels]'], area);
toPixels(form.elements['newZone[MaxAlarmPixels]'], area);
toPixels(form.elements['newZone[MinFilterPixels]'], area);
toPixels(form.elements['newZone[MaxFilterPixels]'], area);
toPixels(form.elements['newZone[MinBlobPixels]'], area);
toPixels(form.elements['newZone[MaxBlobPixels]'], area);
form.elements['newZone[Area]'].value = pixelArea;
toPixels(form.elements['newZone[MinAlarmPixels]'], pixelArea);
toPixels(form.elements['newZone[MaxAlarmPixels]'], pixelArea);
toPixels(form.elements['newZone[MinFilterPixels]'], pixelArea);
toPixels(form.elements['newZone[MaxFilterPixels]'], pixelArea);
toPixels(form.elements['newZone[MinBlobPixels]'], pixelArea);
toPixels(form.elements['newZone[MaxBlobPixels]'], pixelArea);
} else {
form.elements['newZone[Area]'].value = Math.round(area/monitorArea * 100);
toPercent(form.elements['newZone[MinAlarmPixels]'], area);
toPercent(form.elements['newZone[MaxAlarmPixels]'], area);
toPercent(form.elements['newZone[MinFilterPixels]'], area);
toPercent(form.elements['newZone[MaxFilterPixels]'], area);
toPercent(form.elements['newZone[MinBlobPixels]'], area);
toPercent(form.elements['newZone[MaxBlobPixels]'], area);
form.elements['newZone[Area]'].value = Math.round(zone.Area/monitorArea * 100);
toPercent(form.elements['newZone[MinAlarmPixels]'], pixelArea);
toPercent(form.elements['newZone[MaxAlarmPixels]'], pixelArea);
toPercent(form.elements['newZone[MinFilterPixels]'], pixelArea);
toPercent(form.elements['newZone[MaxFilterPixels]'], pixelArea);
toPercent(form.elements['newZone[MinBlobPixels]'], pixelArea);
toPercent(form.elements['newZone[MaxBlobPixels]'], pixelArea);
}
}
@@ -255,9 +258,12 @@ function limitFilter(field) {
function limitArea(field) {
var minValue = 0;
var maxValue = zone.Area;
var maxValue;
if ( document.zoneForm.elements['newZone[Units]'].value == 'Percent' ) {
maxValue = 100;
} else {
// In Pixels mode, max is the zone's pixel area
maxValue = Math.round(zone.Area / monitorArea * monitorPixelArea);
}
if (maxValue > 0) {
limitRange(field, minValue, maxValue);
@@ -295,7 +301,7 @@ function unsetActivePoint(index) {
function getCoordString() {
var coords = [];
for ( let i = 0; i < zone['Points'].length; i++ ) {
coords[coords.length] = zone['Points'][i].x+','+zone['Points'][i].y;
coords[coords.length] = parseFloat(zone['Points'][i].x).toFixed(2)+','+parseFloat(zone['Points'][i].y).toFixed(2);
}
return coords.join(' ');
}
@@ -330,29 +336,28 @@ function constrainValue(value, loVal, hiVal) {
function updateActivePoint(index) {
const point = $j('#point'+index);
const imageFrame = document.getElementById('imageFrame');
const style = imageFrame.currentStyle || window.getComputedStyle(imageFrame);
const padding_left = parseInt(style.paddingLeft);
const padding_top = parseInt(style.paddingTop);
const padding_right = parseInt(style.paddingRight);
const scale = (imageFrame.clientWidth - ( padding_left + padding_right )) / maxX;
const imageFeed = document.getElementById('imageFeed'+zone.MonitorId);
const frameW = imageFeed.clientWidth;
const frameH = imageFeed.clientHeight;
let point_left = parseInt(point.css('left'), 10);
if ( point_left < padding_left ) {
point.css('left', style.paddingLeft);
point_left = parseInt(padding_left);
if ( point_left < 0 ) {
point.css('left', '0px');
point_left = 0;
}
let point_top = parseInt(point.css('top'));
if ( point_top < padding_top ) {
point.css('top', style.paddingTop);
point_top = parseInt(padding_top);
if ( point_top < 0 ) {
point.css('top', '0px');
point_top = 0;
}
var x = constrainValue(Math.ceil(point_left / scale)-Math.ceil(padding_left/scale), 0, maxX);
var y = constrainValue(Math.ceil(point_top / scale)-Math.ceil(padding_top/scale), 0, maxY);
var x = constrainValue(Math.round((point_left / frameW) * maxX * 100) / 100, 0, maxX);
var y = constrainValue(Math.round((point_top / frameH) * maxY * 100) / 100, 0, maxY);
zone['Points'][index].x = document.getElementById('newZone[Points]['+index+'][x]').value = x;
zone['Points'][index].y = document.getElementById('newZone[Points]['+index+'][y]').value = y;
zone['Points'][index].x = x;
zone['Points'][index].y = y;
document.getElementById('newZone[Points]['+index+'][x]').value = x.toFixed(2);
document.getElementById('newZone[Points]['+index+'][y]').value = y.toFixed(2);
var Point = document.getElementById('zonePoly').points.getItem(index);
Point.x = x;
Point.y = y;
@@ -365,8 +370,8 @@ function addPoint(index) {
nextIndex = 0;
}
var newX = parseInt(Math.round((zone['Points'][index]['x']+zone['Points'][nextIndex]['x'])/2));
var newY = parseInt(Math.round((zone['Points'][index]['y']+zone['Points'][nextIndex]['y'])/2));
var newX = Math.round(((parseFloat(zone['Points'][index]['x'])+parseFloat(zone['Points'][nextIndex]['x']))/2) * 100) / 100;
var newY = Math.round(((parseFloat(zone['Points'][index]['y'])+parseFloat(zone['Points'][nextIndex]['y']))/2) * 100) / 100;
if ( nextIndex == 0 ) {
zone['Points'][zone['Points'].length] = {'x': newX, 'y': newY};
} else {
@@ -381,19 +386,19 @@ function delPoint(index) {
}
function limitPointValue(point, loVal, hiVal) {
point.value = constrainValue(point.value, loVal, hiVal);
point.value = constrainValue(parseFloat(point.value), loVal, hiVal);
}
function updateArea( ) {
// Area is calculated in percentage coordinate space (0-100 x 0-100)
const area = Polygon_calcArea(zone['Points']);
zone.Area = area;
const form = document.getElementById('zoneForm');
form.elements['newZone[Area]'].value = area;
if ( form.elements['newZone[Units]'].value == 'Percent' ) {
form.elements['newZone[Area]'].value = Math.round( area/monitorArea*100 );
} else if ( form.elements['newZone[Units]'].value == 'Pixels' ) {
form.elements['newZone[Area]'].value = area;
// Display in current units mode
if ( form.elements['newZone[Units]'].value == 'Pixels' ) {
form.elements['newZone[Area]'].value = Math.round(area / monitorArea * monitorPixelArea);
} else {
alert('Unknown units: ' + form.elements['newZone[Units]'].value);
form.elements['newZone[Area]'].value = Math.round(area / monitorArea * 100);
}
}
@@ -404,14 +409,11 @@ function updateX(input) {
limitPointValue(input, 0, maxX);
const point = $j('#point'+index);
const x = input.value;
const imageFrame = document.getElementById('imageFrame');
const style = imageFrame.currentStyle || window.getComputedStyle(imageFrame);
const padding_left = parseInt(style.paddingLeft);
const padding_right = parseInt(style.paddingRight);
const scale = (imageFrame.clientWidth - ( padding_left + padding_right )) / maxX;
const x = parseFloat(input.value);
const imageFeed = document.getElementById('imageFeed'+zone.MonitorId);
const frameW = imageFeed.clientWidth;
point.css('left', parseInt(x*scale)+'px');
point.css('left', Math.round(x / maxX * frameW) + 'px');
zone['Points'][index].x = x;
const Point = document.getElementById('zonePoly').points.getItem(index);
Point.x = x;
@@ -424,14 +426,11 @@ function updateY(input) {
limitPointValue(input, 0, maxY);
const point = $j('#point'+index);
const y = input.value;
const imageFrame = document.getElementById('imageFrame');
const style = imageFrame.currentStyle || window.getComputedStyle(imageFrame);
const padding_left = parseInt(style.paddingLeft);
const padding_right = parseInt(style.paddingRight);
const scale = (imageFrame.clientWidth - ( padding_left + padding_right )) / maxX;
const y = parseFloat(input.value);
const imageFeed = document.getElementById('imageFeed'+zone.MonitorId);
const frameH = imageFeed.clientHeight;
point.css('top', parseInt(y*scale)+'px');
point.css('top', Math.round(y / maxY * frameH) + 'px');
zone['Points'][index].y = y;
const Point = document.getElementById('zonePoly').points.getItem(index);
Point.y = y;
@@ -454,13 +453,10 @@ function saveChanges(element) {
}
function drawZonePoints() {
var imageFrame = document.getElementById('imageFrame');
var imageFeed = document.getElementById('imageFeed'+zone.MonitorId);
$j('.zonePoint').remove();
var style = imageFrame.currentStyle || window.getComputedStyle(imageFrame);
var padding_left = parseInt(style.paddingLeft);
var padding_right = parseInt(style.paddingRight);
var padding_top = parseInt(style.paddingTop);
var scale = (imageFrame.clientWidth - ( padding_left + padding_right )) / maxX;
var frameW = imageFeed.clientWidth;
var frameH = imageFeed.clientHeight;
$j.each( zone['Points'], function(i, coord) {
var div = $j('<div>');
@@ -471,17 +467,17 @@ function drawZonePoints() {
'title': 'Point '+(i+1)
});
div.css({
left: (Math.round(coord.x * scale) + padding_left)+"px",
top: ((parseInt(coord.y * scale)) + padding_top) +"px"
left: Math.round(parseFloat(coord.x) / maxX * frameW)+"px",
top: Math.round(parseFloat(coord.y) / maxY * frameH)+"px"
});
div.mouseover(highlightOn.bind(i, i));
div.mouseout(highlightOff.bind(i, i));
$j('#imageFrame').append(div);
$j(imageFeed).append(div);
div.draggable({
'containment': document.getElementById('imageFeed'+zone.MonitorId),
'containment': imageFeed,
'start': setActivePoint.bind(i, i),
'stop': fixActivePoint.bind(i, i),
'drag': updateActivePoint.bind(i, i)
@@ -505,11 +501,12 @@ function drawZonePoints() {
$j(input).attr({
'id': 'newZone[Points]['+i+'][x]',
'name': 'newZone[Points]['+i+'][x]',
'value': zone['Points'][i].x,
'value': parseFloat(zone['Points'][i].x).toFixed(2),
'type': 'number',
'class': 'ZonePoint',
'min': '0',
'max': maxX,
'step': 'any',
'data-point-index': i
});
input.oninput = window['updateX'].bind(input, input);
@@ -521,11 +518,12 @@ function drawZonePoints() {
$j(input).attr({
'id': 'newZone[Points]['+i+'][y]',
'name': 'newZone[Points]['+i+'][y]',
'value': zone['Points'][i].y,
'value': parseFloat(zone['Points'][i].y).toFixed(2),
'type': 'number',
'class': 'ZonePoint',
'min': '0',
'max': maxY,
'step': 'any',
'data-point-index': i
});
input.oninput = window['updateY'].bind(input, input);
@@ -710,6 +708,14 @@ function initPage() {
monitors[i].start();
}
// Move the zone SVG into the imageFeed container so it overlays only the image,
// not the status bar below it.
var imageFeed = document.getElementById('imageFeed'+zone.MonitorId);
var zoneSVG = document.getElementById('zoneSVG');
if (imageFeed && zoneSVG) {
imageFeed.appendChild(zoneSVG);
}
document.querySelectorAll('#imageFrame img').forEach(function(el) {
el.addEventListener("load", imageLoadEvent, {passive: true});
});
@@ -755,11 +761,10 @@ function Polygon_calcArea(coords) {
var float_area = 0.0;
for ( i = 0; i < n_coords-1; i++ ) {
var trap_area = (coords[i].x*coords[i+1].y - coords[i+1].x*coords[i].y) / 2;
var trap_area = (parseFloat(coords[i].x)*parseFloat(coords[i+1].y) - parseFloat(coords[i+1].x)*parseFloat(coords[i].y)) / 2;
float_area += trap_area;
//printf( "%.2f (%.2f)\n", float_area, trap_area );
}
float_area += (coords[n_coords-1].x*coords[0].y - coords[0].x*coords[n_coords-1].y) / 2;
float_area += (parseFloat(coords[n_coords-1].x)*parseFloat(coords[0].y) - parseFloat(coords[0].x)*parseFloat(coords[n_coords-1].y)) / 2;
return Math.round(Math.abs(float_area));
}

View File

@@ -57,9 +57,10 @@ zone['Points'][<?php echo $i ?>] = { 'x': <?php echo $zone['Points'][$i]['x'] ?>
}
?>
var maxX = <?php echo $monitor->ViewWidth()-1 ?>;
var maxY = <?php echo $monitor->ViewHeight()-1 ?>;
var monitorArea = <?php echo $monitor->ViewWidth() * $monitor->ViewHeight() ?>;
var maxX = 100;
var maxY = 100;
var monitorArea = 10000;
var monitorPixelArea = <?php echo $monitor->ViewWidth() * $monitor->ViewHeight() ?>;
var monitorData = new Array();
monitorData[monitorData.length] = {

View File

@@ -55,9 +55,9 @@ foreach ( getEnumValues('Zones', 'CheckMethod') as $optCheckMethod ) {
$monitor = new ZM\Monitor($mid);
$minX = 0;
$maxX = $monitor->ViewWidth()-1;
$maxX = 100;
$minY = 0;
$maxY = $monitor->ViewHeight()-1;
$maxY = 100;
if ( !isset($zone) ) {
if ( $zid > 0 ) {
@@ -67,11 +67,11 @@ if ( !isset($zone) ) {
'Id' => 0,
'Name' => translate('New'),
'Type' => 'Active',
'Units' => 'Pixels',
'Units' => 'Percent',
'MonitorId' => $monitor->Id(),
'NumCoords' => 4,
'Coords' => sprintf('%d,%d %d,%d, %d,%d %d,%d', $minX, $minY, $maxX, $minY, $maxX, $maxY, $minX, $maxY),
'Area' => $monitor->ViewWidth() * $monitor->ViewHeight(),
'Coords' => '0.00,0.00 100.00,0.00 100.00,100.00 0.00,100.00',
'Area' => 10000,
'AlarmRGB' => 0xff0000,
'CheckMethod' => 'Blobs',
'MinPixelThreshold' => '',
@@ -142,7 +142,7 @@ echo getNavBarHTML();
<?php echo
$monitor->getStreamHTML(array('mode'=>'single', 'zones'=>false, 'state'=>true));
?>
<svg id="zoneSVG" class="zones" viewBox="0 0 <?php echo $monitor->ViewWidth().' '.$monitor->ViewHeight() ?>">
<svg id="zoneSVG" class="zones" viewBox="0 0 100 100" preserveAspectRatio="none">
<?php
if ( $zone['Id'] ) {
$other_zones = dbFetchAll('SELECT * FROM Zones WHERE MonitorId = ? AND Id != ?', NULL, array($monitor->Id(), $zone['Id']));

View File

@@ -57,9 +57,9 @@ echo getNavBarHTML();
$monitor = $monitors[$mid];
# ViewWidth() and ViewHeight() are already rotated
$minX = 0;
$maxX = $monitor->ViewWidth()-1;
$maxX = 100;
$minY = 0;
$maxY = $monitor->ViewHeight()-1;
$maxY = 100;
$zones = array();
foreach ( dbFetchAll('SELECT * FROM Zones WHERE MonitorId=? ORDER BY Area DESC', NULL, array($mid)) as $row ) {
@@ -93,7 +93,7 @@ echo getNavBarHTML();
<tr>
<td class="colName"><?php echo makeLink('?view=zone&mid='.$mid.'&zid='.$zone['Id'], validHtmlStr($zone['Name']), true, 'data-on-click-true="streamCmdQuit"'); ?></td>
<td class="colType"><?php echo validHtmlStr($zone['Type']) ?></td>
<td class="colUnits"><?php echo $zone['Area'] ?>&nbsp;/&nbsp;<?php echo sprintf('%.2f', ($zone['Area']*100)/($monitor->ViewWidth()*$monitor->ViewHeight()) ) ?></td>
<td class="colUnits"><?php echo intval($zone['Area'] / 10000 * $monitor->ViewWidth() * $monitor->ViewHeight()) ?>&nbsp;/&nbsp;<?php echo sprintf('%.2f', $zone['Area'] / 100) ?></td>
<td class="colMark"><input type="checkbox" name="markZids[]" value="<?php echo $zone['Id'] ?>" data-on-click-this="configureDeleteButton"<?php if ( !canEdit('Monitors') ) { ?> disabled="disabled"<?php } ?>/></td>
</tr>
<?php