Quellcode durchsuchen

fix(web): mask zone editor to handle object filter masks

Includes additional handlers for adding/removing masks, as well as click to copy configs

fixes #523
Paul Armstrong vor 4 Jahren
Ursprung
Commit
d39111a294
6 geänderte Dateien mit 663 neuen und 298 gelöschten Zeilen
  1. 308 232
      web/package-lock.json
  2. 1 1
      web/src/App.jsx
  3. 1 1
      web/src/Camera.jsx
  4. 343 60
      web/src/CameraMap.jsx
  5. 7 2
      web/src/Event.jsx
  6. 3 2
      web/src/components/Button.jsx

+ 308 - 232
web/package-lock.json

@@ -38,27 +38,6 @@
         "source-map": "^0.5.0"
       },
       "dependencies": {
-        "debug": {
-          "version": "4.3.1",
-          "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz",
-          "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==",
-          "requires": {
-            "ms": "2.1.2"
-          }
-        },
-        "json5": {
-          "version": "2.1.3",
-          "resolved": "https://registry.npmjs.org/json5/-/json5-2.1.3.tgz",
-          "integrity": "sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA==",
-          "requires": {
-            "minimist": "^1.2.5"
-          }
-        },
-        "ms": {
-          "version": "2.1.2",
-          "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
-          "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
-        },
         "semver": {
           "version": "5.7.1",
           "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
@@ -945,21 +924,6 @@
         "debug": "^4.1.0",
         "globals": "^11.1.0",
         "lodash": "^4.17.19"
-      },
-      "dependencies": {
-        "debug": {
-          "version": "4.3.1",
-          "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz",
-          "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==",
-          "requires": {
-            "ms": "2.1.2"
-          }
-        },
-        "ms": {
-          "version": "2.1.2",
-          "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
-          "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
-        }
       }
     },
     "@babel/types": {
@@ -1609,9 +1573,9 @@
       "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ=="
     },
     "binary-extensions": {
-      "version": "2.1.0",
-      "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.1.0.tgz",
-      "integrity": "sha512-1Yj8h9Q+QDF5FzhMs/c9+6UntbD5MkRfRwac8DoEm9ZfUBZ7tZ55YcGVAzEe4bXsdQHEk+s9S5wsOKVdZrw0tQ=="
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
+      "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA=="
     },
     "bindings": {
       "version": "1.5.0",
@@ -1845,13 +1809,6 @@
       "integrity": "sha1-hH4PzgoiN1CpoCfFSzNzGtMVQTQ=",
       "requires": {
         "callsites": "^2.0.0"
-      },
-      "dependencies": {
-        "callsites": {
-          "version": "2.0.0",
-          "resolved": "https://registry.npmjs.org/callsites/-/callsites-2.0.0.tgz",
-          "integrity": "sha1-BuuE8A7qQT2oav/vrL/7Ngk7PFA="
-        }
       }
     },
     "caller-path": {
@@ -1863,9 +1820,9 @@
       }
     },
     "callsites": {
-      "version": "3.1.0",
-      "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
-      "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/callsites/-/callsites-2.0.0.tgz",
+      "integrity": "sha1-BuuE8A7qQT2oav/vrL/7Ngk7PFA="
     },
     "camel-case": {
       "version": "3.0.0",
@@ -1898,9 +1855,9 @@
       }
     },
     "caniuse-lite": {
-      "version": "1.0.30001173",
-      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001173.tgz",
-      "integrity": "sha512-R3aqmjrICdGCTAnSXtNyvWYMK3YtV5jwudbq0T7nN9k4kmE4CBuwPqyJ+KBzepSTh0huivV2gLbSMEzTTmfeYw=="
+      "version": "1.0.30001177",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001177.tgz",
+      "integrity": "sha512-6Ld7t3ifCL02jTj3MxPMM5wAYjbo4h/TAQGFTgv1inihP1tWnWp8mxxT4ut4JBEHLbpFXEXJJQ119JCJTBkYDw=="
     },
     "caseless": {
       "version": "0.12.0",
@@ -1918,9 +1875,9 @@
       }
     },
     "chokidar": {
-      "version": "3.5.0",
-      "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.0.tgz",
-      "integrity": "sha512-JgQM9JS92ZbFR4P90EvmzNpSGhpPBGBSj10PILeDyYFwp4h2/D9OM03wsJ4zW1fEp4ka2DGrnUeD7FuvQ2aZ2Q==",
+      "version": "3.5.1",
+      "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.1.tgz",
+      "integrity": "sha512-9+s+Od+W0VJJzawDma/gvBNQqkTiqYTWLuZoyAsivsI4AaWTCzHG06/TMjsf1cYe9Cb97UCEhjz7HvnPk2p/tw==",
       "requires": {
         "anymatch": "~3.1.1",
         "braces": "~3.0.2",
@@ -1943,13 +1900,6 @@
       "integrity": "sha512-9e/zx1jw7B4CO+c/RXoCsfg/x1AfUBioy4owYH0bJprEYAx5hRFLRhWBqHAG57D0ZM4H7vxbP7bPe0VwhQRYDQ==",
       "requires": {
         "tslib": "^1.9.0"
-      },
-      "dependencies": {
-        "tslib": {
-          "version": "1.14.1",
-          "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
-          "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
-        }
       }
     },
     "cipher-base": {
@@ -2069,9 +2019,9 @@
       }
     },
     "commander": {
-      "version": "6.2.1",
-      "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz",
-      "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA=="
+      "version": "2.20.3",
+      "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+      "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="
     },
     "commondir": {
       "version": "1.0.1",
@@ -2180,15 +2130,25 @@
       "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac="
     },
     "cosmiconfig": {
-      "version": "7.0.0",
-      "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.0.tgz",
-      "integrity": "sha512-pondGvTuVYDk++upghXJabWzL6Kxu6f26ljFw64Swq9v6sQPUL3EUlVDV56diOjpCayKihL6hVe8exIACU4XcA==",
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-5.2.1.tgz",
+      "integrity": "sha512-H65gsXo1SKjf8zmrJ67eJk8aIRKV5ff2D4uKZIBZShbhGSpEmsQOPW/SKMKYhSTrqR7ufy6RP69rPogdaPh/kA==",
       "requires": {
-        "@types/parse-json": "^4.0.0",
-        "import-fresh": "^3.2.1",
-        "parse-json": "^5.0.0",
-        "path-type": "^4.0.0",
-        "yaml": "^1.10.0"
+        "import-fresh": "^2.0.0",
+        "is-directory": "^0.3.1",
+        "js-yaml": "^3.13.1",
+        "parse-json": "^4.0.0"
+      },
+      "dependencies": {
+        "parse-json": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz",
+          "integrity": "sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=",
+          "requires": {
+            "error-ex": "^1.3.1",
+            "json-parse-better-errors": "^1.0.1"
+          }
+        }
       }
     },
     "create-ecdh": {
@@ -2321,22 +2281,6 @@
         "semver": "^7.3.2"
       },
       "dependencies": {
-        "icss-utils": {
-          "version": "4.1.1",
-          "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-4.1.1.tgz",
-          "integrity": "sha512-4aFq7wvWyMHKgxsH8QQtGpvbASCf+eM3wPRLI6R+MgAnTCZ6STYsRvttLvRWK0Nfif5piF394St3HeJDaljGPA==",
-          "requires": {
-            "postcss": "^7.0.14"
-          }
-        },
-        "json5": {
-          "version": "2.1.3",
-          "resolved": "https://registry.npmjs.org/json5/-/json5-2.1.3.tgz",
-          "integrity": "sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA==",
-          "requires": {
-            "minimist": "^1.2.5"
-          }
-        },
         "loader-utils": {
           "version": "2.0.0",
           "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.0.tgz",
@@ -2357,43 +2301,6 @@
             "supports-color": "^6.1.0"
           }
         },
-        "postcss-modules-extract-imports": {
-          "version": "2.0.0",
-          "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-2.0.0.tgz",
-          "integrity": "sha512-LaYLDNS4SG8Q5WAWqIJgdHPJrDDr/Lv775rMBFUbgjTz6j34lUznACHcdRWroPvXANP2Vj7yNK57vp9eFqzLWQ==",
-          "requires": {
-            "postcss": "^7.0.5"
-          }
-        },
-        "postcss-modules-local-by-default": {
-          "version": "3.0.3",
-          "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-3.0.3.tgz",
-          "integrity": "sha512-e3xDq+LotiGesympRlKNgaJ0PCzoUIdpH0dj47iWAui/kyTgh3CiAr1qP54uodmJhl6p9rN6BoNcdEDVJx9RDw==",
-          "requires": {
-            "icss-utils": "^4.1.1",
-            "postcss": "^7.0.32",
-            "postcss-selector-parser": "^6.0.2",
-            "postcss-value-parser": "^4.1.0"
-          }
-        },
-        "postcss-modules-scope": {
-          "version": "2.2.0",
-          "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-2.2.0.tgz",
-          "integrity": "sha512-YyEgsTMRpNd+HmyC7H/mh3y+MeFWevy7V1evVhJWewmMbjDHIbZbOXICC2y+m1xI1UVfIT1HMW/O04Hxyu9oXQ==",
-          "requires": {
-            "postcss": "^7.0.6",
-            "postcss-selector-parser": "^6.0.0"
-          }
-        },
-        "postcss-modules-values": {
-          "version": "3.0.0",
-          "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-3.0.0.tgz",
-          "integrity": "sha512-1//E5jCBrZ9DmRX+zCtmQtRSV6PV42Ix7Bzj9GbwJceduuf7IqP8MgeTXuRDHOWj2m0VzZD5+roFWDuU8RQjcg==",
-          "requires": {
-            "icss-utils": "^4.0.0",
-            "postcss": "^7.0.6"
-          }
-        },
         "semver": {
           "version": "7.3.4",
           "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz",
@@ -2435,13 +2342,6 @@
       "requires": {
         "mdn-data": "2.0.14",
         "source-map": "^0.6.1"
-      },
-      "dependencies": {
-        "source-map": {
-          "version": "0.6.1",
-          "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
-          "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
-        }
       }
     },
     "css-unit-converter": {
@@ -2470,35 +2370,6 @@
         "postcss": "^7.0.0"
       },
       "dependencies": {
-        "cosmiconfig": {
-          "version": "5.2.1",
-          "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-5.2.1.tgz",
-          "integrity": "sha512-H65gsXo1SKjf8zmrJ67eJk8aIRKV5ff2D4uKZIBZShbhGSpEmsQOPW/SKMKYhSTrqR7ufy6RP69rPogdaPh/kA==",
-          "requires": {
-            "import-fresh": "^2.0.0",
-            "is-directory": "^0.3.1",
-            "js-yaml": "^3.13.1",
-            "parse-json": "^4.0.0"
-          }
-        },
-        "import-fresh": {
-          "version": "2.0.0",
-          "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-2.0.0.tgz",
-          "integrity": "sha1-2BNVwVYS04bGH53dOSLUMEgipUY=",
-          "requires": {
-            "caller-path": "^2.0.0",
-            "resolve-from": "^3.0.0"
-          }
-        },
-        "parse-json": {
-          "version": "4.0.0",
-          "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz",
-          "integrity": "sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=",
-          "requires": {
-            "error-ex": "^1.3.1",
-            "json-parse-better-errors": "^1.0.1"
-          }
-        },
         "postcss": {
           "version": "7.0.35",
           "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.35.tgz",
@@ -2509,11 +2380,6 @@
             "supports-color": "^6.1.0"
           }
         },
-        "resolve-from": {
-          "version": "3.0.0",
-          "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-3.0.0.tgz",
-          "integrity": "sha1-six699nWiBvItuZTM17rywoYh0g="
-        },
         "supports-color": {
           "version": "6.1.0",
           "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz",
@@ -2676,11 +2542,18 @@
       }
     },
     "debug": {
-      "version": "2.6.9",
-      "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
-      "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+      "version": "4.3.1",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz",
+      "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==",
       "requires": {
-        "ms": "2.0.0"
+        "ms": "2.1.2"
+      },
+      "dependencies": {
+        "ms": {
+          "version": "2.1.2",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+          "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
+        }
       }
     },
     "decimal.js": {
@@ -2886,9 +2759,9 @@
       }
     },
     "electron-to-chromium": {
-      "version": "1.3.634",
-      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.634.tgz",
-      "integrity": "sha512-QPrWNYeE/A0xRvl/QP3E0nkaEvYUvH3gM04ZWYtIa6QlSpEetRlRI1xvQ7hiMIySHHEV+mwDSX8Kj4YZY6ZQAw=="
+      "version": "1.3.641",
+      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.641.tgz",
+      "integrity": "sha512-b0DLhsHSHESC1I+Nx6n4w4Lr61chMd3m/av1rZQhS2IXTzaS5BMM5N+ldWdMIlni9CITMRM09m8He4+YV/92TA=="
     },
     "elliptic": {
       "version": "6.5.3",
@@ -3006,9 +2879,9 @@
       }
     },
     "esbuild": {
-      "version": "0.8.30",
-      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.8.30.tgz",
-      "integrity": "sha512-gCJQYUMO9QNrfpNOIiCnFoX41nWiPFCvURBQF+qWckyJ7gmw2xCShdKCXvS+RZcQ5krcxEOLIkzujqclePKhfw=="
+      "version": "0.8.32",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.8.32.tgz",
+      "integrity": "sha512-5IzQapMW/wFy5oxziHCJzawk26K3xeyrIAQPnPN3c0Q84hqRw6IfGDGfGWOdJNw5tAx77yvwqZ4r1QMpo6emJA=="
     },
     "escalade": {
       "version": "3.1.1",
@@ -3120,6 +2993,14 @@
         "to-regex": "^3.0.1"
       },
       "dependencies": {
+        "debug": {
+          "version": "2.6.9",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+          "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+          "requires": {
+            "ms": "2.0.0"
+          }
+        },
         "define-property": {
           "version": "0.2.5",
           "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz",
@@ -3276,14 +3157,6 @@
         "schema-utils": "^3.0.0"
       },
       "dependencies": {
-        "json5": {
-          "version": "2.1.3",
-          "resolved": "https://registry.npmjs.org/json5/-/json5-2.1.3.tgz",
-          "integrity": "sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA==",
-          "requires": {
-            "minimist": "^1.2.5"
-          }
-        },
         "loader-utils": {
           "version": "2.0.0",
           "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.0.tgz",
@@ -3574,24 +3447,6 @@
         "kind-of": "^4.0.0"
       },
       "dependencies": {
-        "is-number": {
-          "version": "3.0.0",
-          "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
-          "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=",
-          "requires": {
-            "kind-of": "^3.0.2"
-          },
-          "dependencies": {
-            "kind-of": {
-              "version": "3.2.2",
-              "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
-              "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=",
-              "requires": {
-                "is-buffer": "^1.1.5"
-              }
-            }
-          }
-        },
         "kind-of": {
           "version": "4.0.0",
           "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-4.0.0.tgz",
@@ -3693,13 +3548,6 @@
         "param-case": "^2.1.1",
         "relateurl": "^0.2.7",
         "uglify-js": "^3.5.1"
-      },
-      "dependencies": {
-        "commander": {
-          "version": "2.20.3",
-          "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
-          "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="
-        }
       }
     },
     "html-tags": {
@@ -3740,6 +3588,34 @@
         "safer-buffer": ">= 2.1.2 < 3"
       }
     },
+    "icss-utils": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-4.1.1.tgz",
+      "integrity": "sha512-4aFq7wvWyMHKgxsH8QQtGpvbASCf+eM3wPRLI6R+MgAnTCZ6STYsRvttLvRWK0Nfif5piF394St3HeJDaljGPA==",
+      "requires": {
+        "postcss": "^7.0.14"
+      },
+      "dependencies": {
+        "postcss": {
+          "version": "7.0.35",
+          "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.35.tgz",
+          "integrity": "sha512-3QT8bBJeX/S5zKTTjTCIjRF3If4avAT6kqxcASlTWEtAFCb9NH0OUxNDfgZSWdP5fJnBYCMEWkIFfWeugjzYMg==",
+          "requires": {
+            "chalk": "^2.4.2",
+            "source-map": "^0.6.1",
+            "supports-color": "^6.1.0"
+          }
+        },
+        "supports-color": {
+          "version": "6.1.0",
+          "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz",
+          "integrity": "sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==",
+          "requires": {
+            "has-flag": "^3.0.0"
+          }
+        }
+      }
+    },
     "ieee754": {
       "version": "1.2.1",
       "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -3764,18 +3640,18 @@
       }
     },
     "import-fresh": {
-      "version": "3.3.0",
-      "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
-      "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-2.0.0.tgz",
+      "integrity": "sha1-2BNVwVYS04bGH53dOSLUMEgipUY=",
       "requires": {
-        "parent-module": "^1.0.0",
-        "resolve-from": "^4.0.0"
+        "caller-path": "^2.0.0",
+        "resolve-from": "^3.0.0"
       },
       "dependencies": {
         "resolve-from": {
-          "version": "4.0.0",
-          "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
-          "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="
+          "version": "3.0.0",
+          "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-3.0.0.tgz",
+          "integrity": "sha1-six699nWiBvItuZTM17rywoYh0g="
         }
       }
     },
@@ -3972,9 +3848,22 @@
       "integrity": "sha512-2z6JzQvZRa9A2Y7xC6dQQm4FSTSTNWjKIYYTt4246eMTJmIo0Q+ZyOsU66X8lxK1AbB92dFeglPLrhwpeRKO6w=="
     },
     "is-number": {
-      "version": "7.0.0",
-      "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
-      "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
+      "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=",
+      "requires": {
+        "kind-of": "^3.0.2"
+      },
+      "dependencies": {
+        "kind-of": {
+          "version": "3.2.2",
+          "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+          "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=",
+          "requires": {
+            "is-buffer": "^1.1.5"
+          }
+        }
+      }
     },
     "is-obj": {
       "version": "2.0.0",
@@ -4158,6 +4047,11 @@
       "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
       "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="
     },
+    "json-schema": {
+      "version": "0.2.3",
+      "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz",
+      "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM="
+    },
     "json-schema-traverse": {
       "version": "0.4.1",
       "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -4169,11 +4063,11 @@
       "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus="
     },
     "json5": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
-      "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/json5/-/json5-2.1.3.tgz",
+      "integrity": "sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA==",
       "requires": {
-        "minimist": "^1.2.0"
+        "minimist": "^1.2.5"
       }
     },
     "jsonfile": {
@@ -4201,13 +4095,6 @@
         "extsprintf": "1.3.0",
         "json-schema": "0.2.3",
         "verror": "1.10.0"
-      },
-      "dependencies": {
-        "json-schema": {
-          "version": "0.2.3",
-          "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz",
-          "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM="
-        }
       }
     },
     "kind-of": {
@@ -4256,6 +4143,16 @@
         "big.js": "^5.2.2",
         "emojis-list": "^3.0.0",
         "json5": "^1.0.1"
+      },
+      "dependencies": {
+        "json5": {
+          "version": "1.0.1",
+          "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
+          "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
+          "requires": {
+            "minimist": "^1.2.0"
+          }
+        }
       }
     },
     "locate-path": {
@@ -5004,6 +4901,13 @@
       "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
       "requires": {
         "callsites": "^3.0.0"
+      },
+      "dependencies": {
+        "callsites": {
+          "version": "3.1.0",
+          "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+          "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="
+        }
       }
     },
     "parse-asn1": {
@@ -5458,6 +5362,34 @@
       "requires": {
         "cosmiconfig": "^7.0.0",
         "import-cwd": "^3.0.0"
+      },
+      "dependencies": {
+        "cosmiconfig": {
+          "version": "7.0.0",
+          "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.0.tgz",
+          "integrity": "sha512-pondGvTuVYDk++upghXJabWzL6Kxu6f26ljFw64Swq9v6sQPUL3EUlVDV56diOjpCayKihL6hVe8exIACU4XcA==",
+          "requires": {
+            "@types/parse-json": "^4.0.0",
+            "import-fresh": "^3.2.1",
+            "parse-json": "^5.0.0",
+            "path-type": "^4.0.0",
+            "yaml": "^1.10.0"
+          }
+        },
+        "import-fresh": {
+          "version": "3.3.0",
+          "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
+          "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
+          "requires": {
+            "parent-module": "^1.0.0",
+            "resolve-from": "^4.0.0"
+          }
+        },
+        "resolve-from": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+          "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="
+        }
       }
     },
     "postcss-merge-longhand": {
@@ -5688,6 +5620,123 @@
         }
       }
     },
+    "postcss-modules-extract-imports": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-2.0.0.tgz",
+      "integrity": "sha512-LaYLDNS4SG8Q5WAWqIJgdHPJrDDr/Lv775rMBFUbgjTz6j34lUznACHcdRWroPvXANP2Vj7yNK57vp9eFqzLWQ==",
+      "requires": {
+        "postcss": "^7.0.5"
+      },
+      "dependencies": {
+        "postcss": {
+          "version": "7.0.35",
+          "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.35.tgz",
+          "integrity": "sha512-3QT8bBJeX/S5zKTTjTCIjRF3If4avAT6kqxcASlTWEtAFCb9NH0OUxNDfgZSWdP5fJnBYCMEWkIFfWeugjzYMg==",
+          "requires": {
+            "chalk": "^2.4.2",
+            "source-map": "^0.6.1",
+            "supports-color": "^6.1.0"
+          }
+        },
+        "supports-color": {
+          "version": "6.1.0",
+          "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz",
+          "integrity": "sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==",
+          "requires": {
+            "has-flag": "^3.0.0"
+          }
+        }
+      }
+    },
+    "postcss-modules-local-by-default": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-3.0.3.tgz",
+      "integrity": "sha512-e3xDq+LotiGesympRlKNgaJ0PCzoUIdpH0dj47iWAui/kyTgh3CiAr1qP54uodmJhl6p9rN6BoNcdEDVJx9RDw==",
+      "requires": {
+        "icss-utils": "^4.1.1",
+        "postcss": "^7.0.32",
+        "postcss-selector-parser": "^6.0.2",
+        "postcss-value-parser": "^4.1.0"
+      },
+      "dependencies": {
+        "postcss": {
+          "version": "7.0.35",
+          "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.35.tgz",
+          "integrity": "sha512-3QT8bBJeX/S5zKTTjTCIjRF3If4avAT6kqxcASlTWEtAFCb9NH0OUxNDfgZSWdP5fJnBYCMEWkIFfWeugjzYMg==",
+          "requires": {
+            "chalk": "^2.4.2",
+            "source-map": "^0.6.1",
+            "supports-color": "^6.1.0"
+          }
+        },
+        "supports-color": {
+          "version": "6.1.0",
+          "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz",
+          "integrity": "sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==",
+          "requires": {
+            "has-flag": "^3.0.0"
+          }
+        }
+      }
+    },
+    "postcss-modules-scope": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-2.2.0.tgz",
+      "integrity": "sha512-YyEgsTMRpNd+HmyC7H/mh3y+MeFWevy7V1evVhJWewmMbjDHIbZbOXICC2y+m1xI1UVfIT1HMW/O04Hxyu9oXQ==",
+      "requires": {
+        "postcss": "^7.0.6",
+        "postcss-selector-parser": "^6.0.0"
+      },
+      "dependencies": {
+        "postcss": {
+          "version": "7.0.35",
+          "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.35.tgz",
+          "integrity": "sha512-3QT8bBJeX/S5zKTTjTCIjRF3If4avAT6kqxcASlTWEtAFCb9NH0OUxNDfgZSWdP5fJnBYCMEWkIFfWeugjzYMg==",
+          "requires": {
+            "chalk": "^2.4.2",
+            "source-map": "^0.6.1",
+            "supports-color": "^6.1.0"
+          }
+        },
+        "supports-color": {
+          "version": "6.1.0",
+          "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz",
+          "integrity": "sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==",
+          "requires": {
+            "has-flag": "^3.0.0"
+          }
+        }
+      }
+    },
+    "postcss-modules-values": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-3.0.0.tgz",
+      "integrity": "sha512-1//E5jCBrZ9DmRX+zCtmQtRSV6PV42Ix7Bzj9GbwJceduuf7IqP8MgeTXuRDHOWj2m0VzZD5+roFWDuU8RQjcg==",
+      "requires": {
+        "icss-utils": "^4.0.0",
+        "postcss": "^7.0.6"
+      },
+      "dependencies": {
+        "postcss": {
+          "version": "7.0.35",
+          "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.35.tgz",
+          "integrity": "sha512-3QT8bBJeX/S5zKTTjTCIjRF3If4avAT6kqxcASlTWEtAFCb9NH0OUxNDfgZSWdP5fJnBYCMEWkIFfWeugjzYMg==",
+          "requires": {
+            "chalk": "^2.4.2",
+            "source-map": "^0.6.1",
+            "supports-color": "^6.1.0"
+          }
+        },
+        "supports-color": {
+          "version": "6.1.0",
+          "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz",
+          "integrity": "sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==",
+          "requires": {
+            "has-flag": "^3.0.0"
+          }
+        }
+      }
+    },
     "postcss-nested": {
       "version": "5.0.3",
       "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-5.0.3.tgz",
@@ -6312,6 +6361,13 @@
         "glob": "^7.0.0",
         "postcss": "^8.2.1",
         "postcss-selector-parser": "^6.0.2"
+      },
+      "dependencies": {
+        "commander": {
+          "version": "6.2.1",
+          "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz",
+          "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA=="
+        }
       }
     },
     "q": {
@@ -6785,6 +6841,14 @@
         "use": "^3.1.0"
       },
       "dependencies": {
+        "debug": {
+          "version": "2.6.9",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+          "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+          "requires": {
+            "ms": "2.0.0"
+          }
+        },
         "define-property": {
           "version": "0.2.5",
           "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz",
@@ -7389,6 +7453,13 @@
       "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
       "requires": {
         "is-number": "^7.0.0"
+      },
+      "dependencies": {
+        "is-number": {
+          "version": "7.0.0",
+          "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+          "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="
+        }
       }
     },
     "tough-cookie": {
@@ -7409,6 +7480,11 @@
         "punycode": "^2.1.1"
       }
     },
+    "tslib": {
+      "version": "1.14.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
+      "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
+    },
     "tty-browserify": {
       "version": "0.0.0",
       "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz",

+ 1 - 1
web/src/App.jsx

@@ -28,7 +28,7 @@ export default function App() {
         <Sidebar />
         <div className="p-4">
           <Router>
-            <CameraMap path="/cameras/:camera/map-editor" />
+            <CameraMap path="/cameras/:camera/editor" />
             <Camera path="/cameras/:camera" />
             <Event path="/events/:eventId" />
             <Events path="/events" />

+ 1 - 1
web/src/Camera.jsx

@@ -68,7 +68,7 @@ export default function Camera({ camera, url }) {
         <Heading size="sm">Options</Heading>
         <ul>
           <li>
-            <Link href={`/cameras/${camera}/map-editor`}>Mask & Zone editor</Link>
+            <Link href={`/cameras/${camera}/editor`}>Mask & Zone creator</Link>
           </li>
         </ul>
       </div>

+ 343 - 60
web/src/CameraMap.jsx

@@ -6,7 +6,7 @@ import { route } from 'preact-router';
 import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'preact/hooks';
 import { ApiHost, Config } from './context';
 
-export default function Camera({ camera, url }) {
+export default function CameraMasks({ camera, url }) {
   const config = useContext(Config);
   const apiHost = useContext(ApiHost);
   const imageRef = useRef(null);
@@ -17,8 +17,13 @@ export default function Camera({ camera, url }) {
   }
 
   const cameraConfig = config.cameras[camera];
-  const { width, height, mask, zones } = cameraConfig;
-  const [editing, setEditing] = useState('mask');
+  const {
+    width,
+    height,
+    motion: { mask: motionMask },
+    objects: { filters: objectFilters },
+    zones,
+  } = cameraConfig;
 
   useEffect(() => {
     if (!imageRef.current) {
@@ -29,22 +34,50 @@ export default function Camera({ camera, url }) {
     setImageScale(scale);
   }, [imageRef.current, setImageScale]);
 
-  const initialZonePoints = {};
-  if (mask) {
-    initialZonePoints.mask = getPolylinePoints(mask);
-  }
+  const [motionMaskPoints, setMotionMaskPoints] = useState(
+    Array.isArray(motionMask)
+      ? motionMask.map((mask) => getPolylinePoints(mask))
+      : motionMask
+      ? [getPolylinePoints(motionMask)]
+      : []
+  );
+
   const [zonePoints, setZonePoints] = useState(
-    Object.keys(zones).reduce(
-      (memo, zone) => ({ ...memo, [zone]: getPolylinePoints(zones[zone].coordinates) }),
-      initialZonePoints
+    Object.keys(zones).reduce((memo, zone) => ({ ...memo, [zone]: getPolylinePoints(zones[zone].coordinates) }), {})
+  );
+
+  const [objectMaskPoints, setObjectMaskPoints] = useState(
+    Object.keys(objectFilters).reduce(
+      (memo, name) => ({
+        ...memo,
+        [name]: Array.isArray(objectFilters[name].mask)
+          ? objectFilters[name].mask.map((mask) => getPolylinePoints(mask))
+          : objectFilters[name].mask
+          ? [getPolylinePoints(objectFilters[name].mask)]
+          : [],
+      }),
+      {}
     )
   );
 
+  const [editing, setEditing] = useState({ set: motionMaskPoints, key: 0, fn: setMotionMaskPoints });
+
   const handleUpdateEditable = useCallback(
     (newPoints) => {
-      setZonePoints({ ...zonePoints, [editing]: newPoints });
+      let newSet;
+      if (Array.isArray(editing.set)) {
+        newSet = [...editing.set];
+        newSet[editing.key] = newPoints;
+      } else if (editing.subkey !== undefined) {
+        newSet = { ...editing.set };
+        newSet[editing.key][editing.subkey] = newPoints;
+      } else {
+        newSet = { ...editing.set, [editing.key]: newPoints };
+      }
+      editing.set = newSet;
+      editing.fn(newSet);
     },
-    [editing, setZonePoints, zonePoints]
+    [editing]
   );
 
   const handleSelectEditable = useCallback(
@@ -54,49 +87,199 @@ export default function Camera({ camera, url }) {
     [setEditing]
   );
 
+  const handleRemoveEditable = useCallback(
+    (name) => {
+      const filteredZonePoints = Object.keys(zonePoints)
+        .filter((zoneName) => zoneName !== name)
+        .reduce((memo, name) => {
+          memo[name] = zonePoints[name];
+          return memo;
+        }, {});
+      setZonePoints(filteredZonePoints);
+    },
+    [zonePoints, setZonePoints]
+  );
+
+  // Motion mask methods
   const handleAddMask = useCallback(() => {
-    setZonePoints({ mask: [], ...zonePoints });
-  }, [zonePoints, setZonePoints]);
+    const newMotionMaskPoints = [...motionMaskPoints, []];
+    setMotionMaskPoints(newMotionMaskPoints);
+    setEditing({ set: newMotionMaskPoints, key: newMotionMaskPoints.length - 1, fn: setMotionMaskPoints });
+  }, [motionMaskPoints, setMotionMaskPoints]);
+
+  const handleEditMask = useCallback(
+    (key) => {
+      setEditing({ set: motionMaskPoints, key, fn: setMotionMaskPoints });
+    },
+    [setEditing, motionMaskPoints, setMotionMaskPoints]
+  );
+
+  const handleRemoveMask = useCallback(
+    (key) => {
+      const newMotionMaskPoints = [...motionMaskPoints];
+      newMotionMaskPoints.splice(key, 1);
+      setMotionMaskPoints(newMotionMaskPoints);
+    },
+    [motionMaskPoints, setMotionMaskPoints]
+  );
+
+  const handleCopyMotionMasks = useCallback(async () => {
+    await window.navigator.clipboard.writeText(`  motion:
+    mask:
+${motionMaskPoints.map((mask, i) => `      - ${polylinePointsToPolyline(mask)}`).join('\n')}`);
+  }, [motionMaskPoints]);
+
+  // Zone methods
+  const handleEditZone = useCallback(
+    (key) => {
+      setEditing({ set: zonePoints, key, fn: setZonePoints });
+    },
+    [setEditing, zonePoints, setZonePoints]
+  );
 
   const handleAddZone = useCallback(() => {
-    const n = Object.keys(zonePoints).length;
-    const zoneName = `zone-${n}`;
-    setZonePoints({ ...zonePoints, [zoneName]: [] });
-    setEditing(zoneName);
+    const n = Object.keys(zonePoints).filter((name) => name.startsWith('zone_')).length;
+    const zoneName = `zone_${n}`;
+    const newZonePoints = { ...zonePoints, [zoneName]: [] };
+    setZonePoints(newZonePoints);
+    setEditing({ set: newZonePoints, key: zoneName, fn: setZonePoints });
   }, [zonePoints, setZonePoints]);
 
+  const handleRemoveZone = useCallback(
+    (key) => {
+      const newZonePoints = { ...zonePoints };
+      delete newZonePoints[key];
+      setZonePoints(newZonePoints);
+    },
+    [zonePoints, setZonePoints]
+  );
+
+  const handleCopyZones = useCallback(async () => {
+    await window.navigator.clipboard.writeText(`  zones:
+${Object.keys(zonePoints)
+  .map(
+    (zoneName) => `    ${zoneName}:
+      coordinates: ${polylinePointsToPolyline(zonePoints[zoneName])}`
+  )
+  .join('\n')}`);
+  }, [zonePoints]);
+
+  // Object methods
+  const handleEditObjectMask = useCallback(
+    (key, subkey) => {
+      setEditing({ set: objectMaskPoints, key, subkey, fn: setObjectMaskPoints });
+    },
+    [setEditing, objectMaskPoints, setObjectMaskPoints]
+  );
+
+  const handleAddObjectMask = useCallback(() => {
+    const n = Object.keys(objectMaskPoints).filter((name) => name.startsWith('object_')).length;
+    const newObjectName = `object_${n}`;
+    const newObjectMaskPoints = { ...objectMaskPoints, [newObjectName]: [] };
+    setObjectMaskPoints(newObjectMaskPoints);
+    setEditing({ set: newObjectMaskPoints, key: newObjectName, subkey: 0, fn: setObjectMaskPoints });
+  }, [objectMaskPoints, setObjectMaskPoints, setEditing]);
+
+  const handleRemoveObjectMask = useCallback(
+    (key, subkey) => {
+      const newObjectMaskPoints = { ...objectMaskPoints };
+      delete newObjectMaskPoints[key];
+      setObjectMaskPoints(newObjectMaskPoints);
+    },
+    [objectMaskPoints, setObjectMaskPoints]
+  );
+
+  const handleCopyObjectMasks = useCallback(async () => {
+    await window.navigator.clipboard.writeText(`  objects:
+    filters:
+${Object.keys(objectMaskPoints)
+  .map((objectName) =>
+    objectMaskPoints[objectName].length
+      ? `      ${objectName}:
+        mask: ${polylinePointsToPolyline(objectMaskPoints[objectName])}`
+      : ''
+  )
+  .filter(Boolean)
+  .join('\n')}`);
+  }, [objectMaskPoints]);
+
   return (
-    <div>
-      <Heading size="2xl">{camera}</Heading>
+    <div class="flex-col space-y-4" style={`max-width: ${width}px`}>
+      <Heading size="2xl">{camera} mask & zone creator</Heading>
+      <p>
+        This tool can help you create masks & zones for your {camera} camera. When done, copy each mask configuration
+        into your <code className="font-mono">config.yml</code> file restart your Frigate instance to save your changes.
+      </p>
       <div className="relative">
         <img ref={imageRef} width={width} height={height} src={`${apiHost}/api/${camera}/latest.jpg`} />
         <EditableMask
           onChange={handleUpdateEditable}
-          points={zonePoints[editing]}
+          points={editing.subkey ? editing.set[editing.key][editing.subkey] : editing.set[editing.key]}
           scale={imageScale}
           width={width}
           height={height}
         />
       </div>
-      <div class="flex-column space-y-4 overflow-hidden">
-        {Object.keys(zonePoints).map((zone) => (
-          <MaskValues
-            editing={editing === zone}
-            onSelect={handleSelectEditable}
-            points={zonePoints[zone]}
-            name={zone}
-          />
-        ))}
-      </div>
-      <div class="flex flex-grow-0 space-x-4">
-        {!mask ? <Button onClick={handleAddMask}>Add Mask</Button> : null}
-        <Button onClick={handleAddZone}>Add Zone</Button>
+
+      <div class="flex-col space-y-4">
+        <MaskValues
+          editing={editing}
+          title="Motion masks"
+          onCopy={handleCopyMotionMasks}
+          onCreate={handleAddMask}
+          onEdit={handleEditMask}
+          onRemove={handleRemoveMask}
+          points={motionMaskPoints}
+          yamlPrefix={'motion:\n  mask:'}
+          yamlKeyPrefix={maskYamlKeyPrefix}
+        />
+
+        <MaskValues
+          editing={editing}
+          title="Zones"
+          onCopy={handleCopyZones}
+          onCreate={handleAddZone}
+          onEdit={handleEditZone}
+          onRemove={handleRemoveZone}
+          points={zonePoints}
+          yamlPrefix="zones:"
+          yamlKeyPrefix={zoneYamlKeyPrefix}
+        />
+
+        <MaskValues
+          isMulti
+          editing={editing}
+          title="Object masks"
+          onCopy={handleCopyObjectMasks}
+          onCreate={handleAddObjectMask}
+          onEdit={handleEditObjectMask}
+          onRemove={handleRemoveObjectMask}
+          points={objectMaskPoints}
+          yamlPrefix={'objects:\n  filters:'}
+          yamlKeyPrefix={objectYamlKeyPrefix}
+        />
       </div>
     </div>
   );
 }
 
+function maskYamlKeyPrefix(points) {
+  return `    - `;
+}
+
+function zoneYamlKeyPrefix(points, key) {
+  return `  ${key}:
+    coordinates: `;
+}
+
+function objectYamlKeyPrefix(points, key, subkey) {
+  return `        - `;
+}
+
 function EditableMask({ onChange, points, scale, width, height }) {
+  if (!points) {
+    return null;
+  }
   const boundingRef = useRef(null);
 
   function boundedSize(value, maxValue) {
@@ -133,6 +316,7 @@ function EditableMask({ onChange, points, scale, width, height }) {
       const index = points.indexOf(closest);
       const newPoints = [...points];
       newPoints.splice(index, 0, newPoint);
+      console.log(points, newPoints);
       onChange(newPoints);
     },
     [scale, points, onChange]
@@ -180,29 +364,131 @@ function EditableMask({ onChange, points, scale, width, height }) {
   );
 }
 
-function MaskValues({ editing, name, onSelect, points }) {
-  const handleClick = useCallback(() => {
-    onSelect(name);
-  }, [name, onSelect]);
+function MaskValues({
+  isMulti = false,
+  editing,
+  title,
+  onCopy,
+  onCreate,
+  onEdit,
+  onRemove,
+  points,
+  yamlPrefix,
+  yamlKeyPrefix,
+}) {
+  const [showButtons, setShowButtons] = useState(false);
+
+  const handleMousein = useCallback(() => {
+    setShowButtons(true);
+  }, [setShowButtons]);
+
+  const handleMouseout = useCallback(
+    (event) => {
+      const el = event.toElement || event.relatedTarget;
+      if (!el || el.parentNode === event.target) {
+        return;
+      }
+      setShowButtons(false);
+    },
+    [setShowButtons]
+  );
+
+  const handleEdit = useCallback(
+    (event) => {
+      const { key, subkey } = event.target.dataset;
+      onEdit(key, subkey);
+    },
+    [onEdit]
+  );
+
+  const handleRemove = useCallback(
+    (event) => {
+      const { key, subkey } = event.target.dataset;
+      onRemove(key, subkey);
+    },
+    [onRemove]
+  );
 
   return (
     <div
-      className={`rounded border-gray-500 border-solid border p-2 hover:bg-gray-400 cursor-pointer ${
-        editing ? 'bg-gray-300' : ''
-      }`}
-      onclick={handleClick}
+      className="overflow-hidden rounded border-gray-500 border-solid border p-2"
+      onmouseover={handleMousein}
+      onmouseout={handleMouseout}
     >
-      <Heading className="mb-4" size="sm">
-        {name}
-      </Heading>
-      <textarea className="select-all font-mono border-gray-300 text-gray-900 dark:text-gray-100 w-full" readonly>
-        {name === 'mask' ? 'poly,' : null}
-        {polylinePointsToPolyline(points)}
-      </textarea>
+      <div class="flex space-x-4">
+        <Heading className="flex-grow self-center" size="base">
+          {title}
+        </Heading>
+        <Button onClick={onCopy}>Copy</Button>
+        <Button onClick={onCreate}>Add</Button>
+      </div>
+      <pre class="overflow-hidden font-mono text-gray-900 dark:text-gray-100">
+        {yamlPrefix}
+        {Object.keys(points).map((mainkey) => {
+          if (isMulti) {
+            return (
+              <div>
+                {`    ${mainkey}:\n      mask:\n`}
+                {points[mainkey].map((item, subkey) => (
+                  <Item
+                    mainkey={mainkey}
+                    subkey={subkey}
+                    editing={editing}
+                    handleEdit={handleEdit}
+                    points={item}
+                    showButtons={showButtons}
+                    handleRemove={handleRemove}
+                    yamlKeyPrefix={yamlKeyPrefix}
+                  />
+                ))}
+              </div>
+            );
+          } else {
+            return (
+              <Item
+                mainkey={mainkey}
+                editing={editing}
+                handleEdit={handleEdit}
+                points={points[mainkey]}
+                showButtons={showButtons}
+                handleRemove={handleRemove}
+                yamlKeyPrefix={yamlKeyPrefix}
+              />
+            );
+          }
+        })}
+      </pre>
     </div>
   );
 }
 
+function Item({ mainkey, subkey, editing, handleEdit, points, showButtons, handleRemove, yamlKeyPrefix }) {
+  return (
+    <span
+      data-key={mainkey}
+      data-subkey={subkey}
+      className={`block hover:text-blue-400 cursor-pointer relative ${
+        editing.key === mainkey && editing.subkey === subkey ? 'text-blue-800 dark:text-blue-600' : ''
+      }`}
+      onClick={handleEdit}
+      title="Click to edit"
+    >
+      {`${yamlKeyPrefix(points, mainkey, subkey)}${polylinePointsToPolyline(points)}`}
+      {showButtons ? (
+        <Button
+          className="absolute top-0 right-0"
+          color="red"
+          data-key={mainkey}
+          data-subkey={subkey}
+          onClick={handleRemove}
+        >
+          Remove
+        </Button>
+      ) : null}
+    </span>
+  );
+}
+
 function distance([x0, y0], [x1, y1]) {
   return Math.sqrt(Math.pow(x0 - x1, 2) + Math.pow(y0 - y1, 2));
 }
@@ -212,17 +498,14 @@ function getPolylinePoints(polyline) {
     return;
   }
 
-  return polyline
-    .replace('poly,', '')
-    .split(',')
-    .reduce((memo, point, i) => {
-      if (i % 2) {
-        memo[memo.length - 1].push(parseInt(point, 10));
-      } else {
-        memo.push([parseInt(point, 10)]);
-      }
-      return memo;
-    }, []);
+  return polyline.split(',').reduce((memo, point, i) => {
+    if (i % 2) {
+      memo[memo.length - 1].push(parseInt(point, 10));
+    } else {
+      memo.push([parseInt(point, 10)]);
+    }
+    return memo;
+  }, []);
 }
 
 function scalePolylinePoints(polylinePoints, scale) {

+ 7 - 2
web/src/Event.jsx

@@ -30,10 +30,15 @@ export default function Event({ eventId }) {
         {data.camera} {data.label} <span className="text-sm">{datetime.toLocaleString()}</span>
       </Heading>
       <img
-        src={`data:image/jpeg;base64,${data.thumbnail}`}
+        src={`${apiHost}/clips/${data.camera}-${eventId}.jpg`}
         alt={`${data.label} at ${(data.top_score * 100).toFixed(1)}% confidence`}
       />
-      <video className="w-96" src={`${apiHost}/clips/${data.camera}-${eventId}.mp4`} controls />
+      {data.has_clip ? (
+        <video className="w-96" src={`${apiHost}/clips/${data.camera}-${eventId}.mp4`} controls />
+      ) : (
+        <p>No clip available</p>
+      )}
+
       <pre>{JSON.stringify(data, null, 2)}</pre>
     </div>
   );

+ 3 - 2
web/src/components/Button.jsx

@@ -2,13 +2,14 @@ import { h } from 'preact';
 
 const noop = () => {};
 
-export default function Button({ children, color, onClick, size }) {
+export default function Button({ children, className, color = 'blue', onClick, size, ...attrs }) {
   return (
     <div
       role="button"
       tabindex="0"
-      className="rounded bg-blue-500 text-white pl-4 pr-4 pt-2 pb-2 font-bold shadow hover:bg-blue-400 hover:shadow-lg cursor-pointer"
+      className={`rounded bg-${color}-500 text-white pl-4 pr-4 pt-2 pb-2 font-bold shadow hover:bg-${color}-400 hover:shadow-lg cursor-pointer ${className}`}
       onClick={onClick || noop}
+      {...attrs}
     >
       {children}
     </div>