auth

Main authentication module for my projects
git clone git://git.finwo.net/app/auth
Log | Files | Refs

commit 1fb2e00ec0d6a24763d828d7465876c3d03a5603
parent b73e4d0d1864eae600baf99db471ee0f0803c68d
Author: finwo <finwo@pm.me>
Date:   Tue, 20 Oct 2026 01:04:16 +0200

Init login page styling

Diffstat:
M.gitignore | 1+
Aserver/auth.example.yaml | 8++++++++
Aserver/migrations/sqlite/realm/001_init.sql | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aserver/migrations/sqlite/system/001_init.sql | 14++++++++++++++
Mserver/package-lock.json | 411+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mserver/package.json | 8+++++++-
Aserver/scripts/generate-hex-grid.js | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aserver/scripts/seed-demo.js | 144+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aserver/src/app.js | 9+++++++++
Aserver/src/controller/login/password.js | 10++++++++++
Aserver/src/controller/login/resolve.js | 32++++++++++++++++++++++++++++++++
Aserver/src/controller/manifest.js | 10++++++++++
Aserver/src/controller/realm/show.js | 16++++++++++++++++
Mserver/src/index.js | 205++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Mserver/src/lib/config.js | 43++++++++++++++++++++++++++++++++++++-------
Aserver/src/lib/db/index.js | 19+++++++++++++++++++
Aserver/src/lib/db/migrate.js | 211+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aserver/src/lib/db/params.js | 31+++++++++++++++++++++++++++++++
Aserver/src/lib/db/query.js | 209+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aserver/src/lib/db/sqlite.js | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aserver/src/lib/http/http-error.js | 18++++++++++++++++++
Aserver/src/lib/http/http-ok.js | 7+++++++
Aserver/src/lib/http/http-response.js | 22++++++++++++++++++++++
Aserver/src/lib/login.js | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aserver/src/lib/realm.js | 307+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aserver/test/config.test.js | 132+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aserver/test/realm.test.js | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mserver/web/esbuild.cjs | 12+++---------
Mserver/web/src/assets/global.css | 192++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Aserver/web/src/assets/hex-grid-dark.svg | 339+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aserver/web/src/assets/hex-grid-light.svg | 339+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dserver/web/src/component/layout.tsx | 106-------------------------------------------------------------------------------
Mserver/web/src/main.ts | 28++++++++++++----------------
Dserver/web/src/screen/home.tsx | 11-----------
Dserver/web/src/screen/login-pre-tenant.tsx | 11-----------
Aserver/web/src/screen/login.tsx | 221+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aserver/web/src/util/api.ts | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
37 files changed, 3385 insertions(+), 247 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -1,2 +1,3 @@ node_modules/ server/web/dist/ +server/data/ diff --git a/server/auth.example.yaml b/server/auth.example.yaml @@ -0,0 +1,8 @@ +server: + host: 0.0.0.0 + port: 3000 + +database: + dialect: sqlite + path: ./data/system.sqlite + realms: ./data/realms diff --git a/server/migrations/sqlite/realm/001_init.sql b/server/migrations/sqlite/realm/001_init.sql @@ -0,0 +1,82 @@ +CREATE TABLE realm_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + slug TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + kind TEXT NOT NULL DEFAULT 'realm' CHECK (kind IN ('realm', 'system')), + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')), + branding TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + username_normalized TEXT NOT NULL UNIQUE, + email TEXT, + email_normalized TEXT UNIQUE, + display_name TEXT, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')), + profile TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE credentials ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + type TEXT NOT NULL, + secret_hash TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE (user_id, type) +); + +CREATE INDEX credentials_user ON credentials(user_id); + +CREATE TABLE roles ( + id TEXT PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + created_at INTEGER NOT NULL +); + +CREATE TABLE user_roles ( + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, role_id) +); + +CREATE INDEX user_roles_role ON user_roles(role_id); + +CREATE TABLE identity_providers ( + id TEXT PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + issuer TEXT NOT NULL, + client_id TEXT NOT NULL, + client_secret TEXT, + scopes TEXT NOT NULL DEFAULT 'openid email profile', + enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE email_domains ( + domain TEXT PRIMARY KEY, + provider_id TEXT NOT NULL REFERENCES identity_providers(id) ON DELETE CASCADE, + created_at INTEGER NOT NULL +); + +CREATE INDEX email_domains_provider ON email_domains(provider_id); + +CREATE TABLE oidc_clients ( + id TEXT PRIMARY KEY, + client_id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + redirect_uris TEXT NOT NULL DEFAULT '[]', + scopes TEXT NOT NULL DEFAULT 'openid profile email', + enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); diff --git a/server/migrations/sqlite/system/001_init.sql b/server/migrations/sqlite/system/001_init.sql @@ -0,0 +1,14 @@ +CREATE TABLE realms ( + id TEXT PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + db_file TEXT, + created_at INTEGER NOT NULL +); + +CREATE TABLE realm_domains ( + host TEXT PRIMARY KEY, + realm_id TEXT NOT NULL REFERENCES realms(id) ON DELETE CASCADE, + created_at INTEGER NOT NULL +); + +CREATE INDEX realm_domains_realm ON realm_domains(realm_id); diff --git a/server/package-lock.json b/server/package-lock.json @@ -6,6 +6,7 @@ "": { "dependencies": { "@finwo/scandir": "^0.0.3", + "body-parser": "^2.3.0", "cors": "^2.8.6", "mithril": "^2.3.8", "morgan": "^1.12.1", @@ -539,6 +540,53 @@ "node": ">= 0.8" } }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -552,6 +600,57 @@ "node": ">=8" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/copy-file": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/copy-file/-/copy-file-11.1.0.tgz", @@ -625,6 +724,20 @@ "node": ">= 0.8" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -640,6 +753,36 @@ "node": ">= 0.8" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", @@ -746,6 +889,52 @@ "node": ">= 0.8" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", @@ -781,6 +970,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -788,6 +989,30 @@ "dev": true, "license": "ISC" }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -808,6 +1033,22 @@ "url": "https://opencollective.com/express" } }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/ignore": { "version": "7.0.13", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.13.tgz", @@ -889,6 +1130,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -979,6 +1242,18 @@ "node": ">=0.10.0" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -1090,6 +1365,22 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/qs": { + "version": "6.16.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz", + "integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -1124,6 +1415,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -1204,6 +1510,12 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", @@ -1278,6 +1590,78 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/slash": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", @@ -1322,6 +1706,24 @@ "node": ">=0.6" } }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/unicorn-magic": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.1.tgz", @@ -1335,6 +1737,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/server/package.json b/server/package.json @@ -1,10 +1,16 @@ { + "type": "module", "scripts": { + "build": "web/esbuild.cjs", + "assets:hex": "node scripts/generate-hex-grid.js", + "prestart": "npm run build", "start": "node src/index.js", - "build": "web/esbuild.cjs" + "seed:demo": "node scripts/seed-demo.js", + "test": "node --test \"test/**/*.test.js\"" }, "dependencies": { "@finwo/scandir": "^0.0.3", + "body-parser": "^2.3.0", "cors": "^2.8.6", "mithril": "^2.3.8", "morgan": "^1.12.1", diff --git a/server/scripts/generate-hex-grid.js b/server/scripts/generate-hex-grid.js @@ -0,0 +1,89 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SETTINGS = { + width: 1024, + height: 576, + amount: 20, + density: 1, + strokeWidth: 0.5, + baseWidth: 512, + randomFillOpacity: true, + light: { + back: '#FFF', + front: '#DDD', + }, + dark: { + back: '#000', + front: '#222', + }, +}; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const assets = path.resolve(here, '../web/src/assets'); + +function randomAt(column, row) { + let value = Math.imul(column + 1000, 374761393) + Math.imul(row + 1000, 668265263); + value = Math.imul(value ^ (value >>> 13), 1274126177); + return ((value ^ (value >>> 16)) >>> 0) / 4294967295; +} + +function hexPath(cx, cy, radius, halfWidth) { + const halfHeight = radius / 2; + const points = [ + [cx, cy - radius], + [cx + halfWidth, cy - halfHeight], + [cx + halfWidth, cy + halfHeight], + [cx, cy + radius], + [cx - halfWidth, cy + halfHeight], + [cx - halfWidth, cy - halfHeight], + ].map(([x, y]) => `${Number(x.toFixed(3))} ${Number(y.toFixed(3))}`); + return `M ${points[0]} L ${points[1]} L ${points[2]} L ${points[3]} L ${points[4]} L ${points[5]} Z`; +} + +function buildSvg({ front, back }) { + const { width, height, amount, density, strokeWidth, baseWidth } = SETTINGS; + const spacing = width / amount; + const radius = (spacing / Math.sqrt(3)) * density; + const verticalSpacing = 1.5 * radius; + const stroke = strokeWidth * (width / baseWidth); + const rows = Math.ceil(height / verticalSpacing) + 1; + + const paths = []; + for (let row = -1; row <= rows; row += 1) { + const parity = ((row % 2) + 2) % 2; + const cy = radius + verticalSpacing * row; + for (let column = 0; column <= amount; column += 1) { + const cx = parity * (spacing / 2) + spacing * column; + const random = randomAt(column, row); + const opacity = !SETTINGS.randomFillOpacity + ? 1 + : random >= 0.45 + ? Number((random ** 2).toFixed(2)) + : 0; + const fill = opacity > 0 ? ` fill="${front}" fill-opacity="${opacity}"` : ' fill="none"'; + paths.push( + `<path stroke="${front}" stroke-width="${Number(stroke.toFixed(4))}"${fill} d="${hexPath( + cx, + cy, + radius, + spacing / 2 + )}"/>` + ); + } + } + + return [ + `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`, + `<rect width="100%" height="100%" fill="${back}"/>`, + ...paths, + '</svg>', + '', + ].join('\n'); +} + +fs.writeFileSync(path.join(assets, 'hex-grid-light.svg'), buildSvg(SETTINGS.light)); +fs.writeFileSync(path.join(assets, 'hex-grid-dark.svg'), buildSvg(SETTINGS.dark)); + +console.log('wrote hex-grid-light.svg and hex-grid-dark.svg'); diff --git a/server/scripts/seed-demo.js b/server/scripts/seed-demo.js @@ -0,0 +1,144 @@ +import crypto from 'node:crypto'; +import { loadConfig } from '../src/lib/config.js'; +import { + closeRealms, + createRealm, + findRealmBySlug, + getSystemDatabase, + initRealms, + openRealmDatabase, +} from '../src/lib/realm.js'; + +function normalize(input) { + return String(input || '').trim().toLowerCase(); +} + +async function ensureUser(db, { username, email, displayName }) { + const normalizedEmail = email ? normalize(email) : null; + const existing = await db.get( + 'SELECT id FROM users WHERE username_normalized = {username}', + { username: normalize(username) } + ); + if (existing) return existing.id; + + const now = Date.now(); + const id = crypto.randomUUID(); + await db.run( + `INSERT INTO users + (id, username, username_normalized, email, email_normalized, display_name, status, profile, created_at, updated_at) + VALUES + ({id}, {username}, {usernameNormalized}, {email}, {emailNormalized}, {displayName}, 'active', '{}', {createdAt}, {updatedAt})`, + { + id, + username, + usernameNormalized: normalize(username), + email, + emailNormalized: normalizedEmail, + displayName: displayName || username, + createdAt: now, + updatedAt: now, + } + ); + return id; +} + +async function grantRole(db, userId, slug) { + const role = await db.get('SELECT id FROM roles WHERE slug = {slug}', { slug }); + if (!role) throw new Error(`missing role: ${slug}`); + const existing = await db.get( + 'SELECT user_id FROM user_roles WHERE user_id = {userId} AND role_id = {roleId}', + { userId, roleId: role.id } + ); + if (existing) return; + await db.run( + 'INSERT INTO user_roles (user_id, role_id) VALUES ({userId}, {roleId})', + { userId, roleId: role.id } + ); +} + +async function ensureProvider(db, { slug, displayName, issuer, clientId, clientSecret }) { + const existing = await db.get('SELECT id FROM identity_providers WHERE slug = {slug}', { slug }); + if (existing) return existing.id; + const now = Date.now(); + const id = crypto.randomUUID(); + await db.run( + `INSERT INTO identity_providers + (id, slug, display_name, issuer, client_id, client_secret, scopes, enabled, created_at, updated_at) + VALUES + ({id}, {slug}, {displayName}, {issuer}, {clientId}, {clientSecret}, 'openid email profile', 1, {createdAt}, {updatedAt})`, + { + id, + slug, + displayName, + issuer, + clientId, + clientSecret: clientSecret || null, + createdAt: now, + updatedAt: now, + } + ); + return id; +} + +async function ensureDomain(db, domain, providerId) { + const existing = await db.get('SELECT domain FROM email_domains WHERE domain = {domain}', { + domain: normalize(domain), + }); + if (existing) return; + await db.run( + 'INSERT INTO email_domains (domain, provider_id, created_at) VALUES ({domain}, {providerId}, {createdAt})', + { domain: normalize(domain), providerId, createdAt: Date.now() } + ); +} + +const cfg = loadConfig(); +await initRealms(cfg); + +let acme = await findRealmBySlug('acme'); +if (!acme) { + acme = await createRealm({ + slug: 'acme', + displayName: 'Acme', + branding: { primary_color: '#25A' }, + hosts: ['acme.localhost'], + }); +} + +const acmeDb = await openRealmDatabase(acme); +const alice = await ensureUser(acmeDb, { + username: 'alice', + email: 'alice@local.test', + displayName: 'Alice', +}); +const acmeAdmin = await ensureUser(acmeDb, { + username: 'acme-it', + email: 'it@local.test', + displayName: 'Acme IT', +}); +await grantRole(acmeDb, acmeAdmin, 'realm-admin'); + +const providerId = await ensureProvider(acmeDb, { + slug: 'example-sso', + displayName: 'Example SSO', + issuer: 'https://idp.example.com', + clientId: 'demo', + clientSecret: 'demo-secret', +}); +await ensureDomain(acmeDb, 'example.com', providerId); + +const systemDb = getSystemDatabase(); +const systemAdmin = await ensureUser(systemDb, { + username: 'admin', + email: 'admin@system.test', + displayName: 'System Admin', +}); +await grantRole(systemDb, systemAdmin, 'system-admin'); + +console.log('seeded realm acme (host acme.localhost)'); +console.log('local user: alice@local.test'); +console.log('upstream mapping: example.com -> Example SSO'); +console.log('realm admin: it@local.test'); +console.log('system admin: admin@system.test'); +console.log(`alice id: ${alice}`); + +await closeRealms(); diff --git a/server/src/app.js b/server/src/app.js @@ -0,0 +1,9 @@ +import Router from 'router'; + +export const app = new Router(); +app.manifest = []; +app.history = []; + +globalThis.app = app; + +export default app; diff --git a/server/src/controller/login/password.js b/server/src/controller/login/password.js @@ -0,0 +1,10 @@ +import { HttpError } from '../../lib/http/http-error.js'; + +export default { + method: 'post', + path: '/api/v1/login/password', + name: 'login.password', + async handler() { + throw new HttpError(501, 'not_implemented', 'Password sign-in is not implemented'); + }, +}; diff --git a/server/src/controller/login/resolve.js b/server/src/controller/login/resolve.js @@ -0,0 +1,32 @@ +import { app } from '../../app.js'; +import { resolveLogin } from '../../lib/login.js'; +import { HttpError } from '../../lib/http/http-error.js'; +import { openRealmDatabase, realmMetadata, resolveRealm } from '../../lib/realm.js'; + +export default { + method: 'post', + path: '/api/v1/login/resolve', + name: 'login.resolve', + async handler(req) { + const body = req.body || {}; + if (typeof body.identifier !== 'string') { + throw new HttpError(400, 'invalid_request', 'identifier is required'); + } + if (body.realm != null && typeof body.realm !== 'string') { + throw new HttpError(400, 'invalid_request', 'realm must be a string'); + } + + const realm = await resolveRealm({ + host: req.headers.host, + slug: body.realm || undefined, + }); + const metadata = await realmMetadata(realm); + const db = await openRealmDatabase(realm); + const result = await resolveLogin(db, body.identifier); + + return new app.HttpOk({ + realm: metadata, + ...result, + }); + }, +}; diff --git a/server/src/controller/manifest.js b/server/src/controller/manifest.js @@ -0,0 +1,10 @@ +import { app } from '../app.js'; + +export default { + method: 'get', + path: '/api/v1/manifest.json', + name: 'manifest', + async handler() { + return new app.HttpOk(app.manifest); + }, +}; diff --git a/server/src/controller/realm/show.js b/server/src/controller/realm/show.js @@ -0,0 +1,16 @@ +import { app } from '../../app.js'; +import { realmMetadata, resolveRealm } from '../../lib/realm.js'; + +export default { + method: 'get', + path: '/api/v1/realm', + name: 'realm.show', + async handler(req) { + const url = new URL(req.url, 'http://localhost'); + const realm = await resolveRealm({ + host: req.headers.host, + slug: url.searchParams.get('realm') || undefined, + }); + return new app.HttpOk(await realmMetadata(realm)); + }, +}; diff --git a/server/src/index.js b/server/src/index.js @@ -1,104 +1,139 @@ +import bodyParser from 'body-parser'; +import cors from 'cors'; +import fs from 'node:fs'; +import http from 'node:http'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import morgan from 'morgan'; +import scandir from '@finwo/scandir'; +import serveStatic from 'serve-static'; +import { app } from './app.js'; +import { loadConfig } from './lib/config.js'; +import { closeRealms, initRealms } from './lib/realm.js'; -// const bodyParser = require('body-parser'); -const cors = require('cors'); -const fs = require('fs'); -const http = require('http'); -const morgan = require('morgan'); -const scandir = require('@finwo/scandir'); -// const Sequelize = require('sequelize'); -const serveStatic = require('serve-static'); - -const { loadConfig } = require('./lib/config'); +const here = path.dirname(fileURLToPath(import.meta.url)); +const dist = path.join(here, '..', 'web', 'dist'); const cfg = loadConfig(); +app.config = cfg; -// // Initialize app -const Router = require('router'); -global.app = new Router(); -// app.manifest = []; -// app.history = []; -// app.config = require('rc')('fauth', require('./config')); - -// Pre-compile regexes -app.regex = { - email: /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/, -}; - -// // Initialize common responses -// scandir.sync(`${__dirname}/lib/http`, response => Object.assign(app,response), ['js'], true); +async function loadModules(dir) { + const files = []; + scandir.sync(dir, (file) => files.push(file), ['js'], false); + files.sort(); + const modules = []; + for (const file of files) { + modules.push(await import(pathToFileURL(file).href)); + } + return modules; +} -// // Initialize database -// app.db = new Sequelize(app.config.db); -// scandir.sync(`${__dirname}/model`, factory => factory(app.db), ['js'], true); -// app.db.sync({ alter: true }); +for (const mod of await loadModules(path.join(here, 'lib', 'http'))) { + Object.assign(app, mod); +} -// Load middleware app.use(morgan('tiny')); app.use(cors()); -// app.use(bodyParser.json()); +app.use(bodyParser.json({ limit: '16kb' })); -// Fetch all routes const routes = []; -// scandir.sync(`${__dirname}/controller`, function logRoute(route) { -// if (Array.isArray(route)) return route.map(logRoute); -// if (route.name) { -// app.manifest.push({ -// method: route.method, -// path : route.path, -// name : route.name, -// }); -// } -// routes.push(route); -// }, ['js'], true); - -// // Order routes by specificness (matters in our router) -// routes.sort((A, B) => { -// const aparts = A.path.split('/'); -// const bparts = B.path.split('/'); -// for(let i=0; i<Math.min(aparts.length,bparts.length); i++) { -// if ( -// (aparts[i].substr(0,1) === ':') && -// (bparts[i].substr(0,1) !== ':') -// ) { -// return 1; -// } -// if ( -// (aparts[i].substr(0,1) !== ':') && -// (bparts[i].substr(0,1) === ':') -// ) { -// return -1; -// } -// } -// return bparts.length - aparts.length; -// }); - -// // Register all routes -// for(const route of routes) { -// app[route.method](route.path, async (req, res, ...args) => { -// const result = await route.handler(req, res, ...args); -// if (result instanceof app.HttpResponse) result.send(req, res); -// }); -// } - -// Setup static router -const serve = serveStatic(`${__dirname}/../web/dist`, { - index: [ - 'index.htm', - 'index.html', - ], +for (const mod of await loadModules(path.join(here, 'controller'))) { + const exported = mod.default ?? mod.route ?? mod; + for (const route of Array.isArray(exported) ? exported : [exported]) { + if (!route || !route.method || !route.path) continue; + if (route.name) { + app.manifest.push({ + method: route.method, + path: route.path, + name: route.name, + }); + } + routes.push(route); + } +} + +routes.sort((A, B) => { + const aparts = A.path.split('/'); + const bparts = B.path.split('/'); + for (let i = 0; i < Math.min(aparts.length, bparts.length); i += 1) { + if (aparts[i].startsWith(':') && !bparts[i].startsWith(':')) return 1; + if (!aparts[i].startsWith(':') && bparts[i].startsWith(':')) return -1; + } + return bparts.length - aparts.length; }); -// Setup server & start listening -http.createServer((req, res) => { - const indexContent = fs.readFileSync(`${__dirname}/../web/dist/index.html`); - app(req, res, () => { +for (const route of routes) { + app[route.method](route.path, async (req, res, ...args) => { + try { + const result = await route.handler(req, res, ...args); + if (result instanceof app.HttpResponse) result.send(res); + } catch (e) { + if (res.headersSent) { + res.end(); + return; + } + if (e instanceof app.HttpError) { + e.toResponse().send(res); + return; + } + console.error(e); + new app.HttpError(500, 'internal_error', 'Internal server error').toResponse().send(res); + } + }); +} + +const serve = serveStatic(dist, { + index: ['index.htm', 'index.html'], +}); + +let indexContent = null; +try { + indexContent = fs.readFileSync(path.join(dist, 'index.html')); +} catch { + indexContent = null; +} + +await initRealms(cfg); + +const server = http.createServer((req, res) => { + app(req, res, (err) => { + if (err) { + const status = err.status || err.statusCode || 500; + const code = err.type === 'entity.parse.failed' ? 'invalid_json' : 'internal_error'; + res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify({ error: { code, message: err.message || 'Request failed' } })); + return; + } + serve(req, res, () => { - res.setHeader('content-type', 'text/html'); + if (!indexContent) { + res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' }); + res.end('frontend build missing'); + return; + } + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); res.end(indexContent); }); }); -}).listen(cfg.server.port, cfg.server.host, err => { - if (err) throw err; +}); + +server.listen(cfg.server.port, cfg.server.host, () => { console.log(`Listening on ${cfg.server.host}:${cfg.server.port}`); }); + +let shuttingDown = false; + +async function shutdown(signal) { + if (shuttingDown) return; + shuttingDown = true; + console.log(`${signal} received, shutting down`); + server.close(); + await closeRealms(); +} + +for (const signal of ['SIGINT', 'SIGTERM']) { + process.once(signal, () => { + shutdown(signal).finally(() => process.exit(0)); + }); +} diff --git a/server/src/lib/config.js b/server/src/lib/config.js @@ -5,22 +5,36 @@ // 2. YAML file, from ./auth.yaml when present // 3. environment variables (see ENV_MAP) -const fs = require('fs'); -const YAML = require('yaml'); +import fs from 'node:fs'; +import YAML from 'yaml'; + +const SUPPORTED_DIALECTS = ['sqlite']; const DEFAULTS = { server: { host: '0.0.0.0', port: 3000, }, + database: { + dialect: 'sqlite', + path: './data/system.sqlite', + realms: './data/realms', + }, }; // [environment variable, dotted config path, parser] const ENV_MAP = [ ['AUTH_HOST', 'server.host', String], ['AUTH_PORT', 'server.port', toInt], + ['AUTH_DATABASE_DIALECT', 'database.dialect', lower], + ['AUTH_DATABASE_PATH', 'database.path', String], + ['AUTH_REALMS_PATH', 'database.realms', String], ]; +function lower(v) { + return String(v).trim().toLowerCase(); +} + function toInt(v) { const n = parseInt(v, 10); if (!Number.isFinite(n)) throw new Error(`expected an integer, got ${JSON.stringify(v)}`); @@ -66,12 +80,29 @@ function validate(cfg) { errors.push(`server.port must be between 0 and 65535, got ${port}`); } + if (!SUPPORTED_DIALECTS.includes(cfg.database.dialect)) { + errors.push( + `database.dialect must be one of ${SUPPORTED_DIALECTS.join(', ')}, got ${JSON.stringify(cfg.database.dialect)}` + ); + } + + if (cfg.database.dialect === 'sqlite') { + if (!cfg.database.path) { + errors.push('database.path is required for sqlite (set database.path or AUTH_DATABASE_PATH)'); + } + if (!cfg.database.realms) { + errors.push('database.realms is required for sqlite (set database.realms or AUTH_REALMS_PATH)'); + } + } + + + if (errors.length > 0) { throw new Error(`invalid configuration:\n - ${errors.join('\n - ')}`); } } -function loadConfig(explicitPath) { +export function loadConfig(explicitPath) { const file = explicitPath || 'auth.yaml'; let cfg = structuredClone(DEFAULTS); let source = null; @@ -103,11 +134,9 @@ function loadConfig(explicitPath) { } } - validate(cfg); - cfg.source = source; + validate(cfg); + return deepFreeze(cfg); } - -module.exports = { loadConfig }; diff --git a/server/src/lib/db/index.js b/server/src/lib/db/index.js @@ -0,0 +1,19 @@ +// src/lib/db/index.js - database driver selection. +// +// Drivers expose all(), get(), run(), exec(), transaction() and close(). +// Queries use named {name} markers. + +import { openSqlite } from './sqlite.js'; + +export const DIALECTS = ['sqlite']; + +export async function openDatabase({ dialect = 'sqlite', path }) { + switch (dialect) { + case 'sqlite': + return openSqlite({ database: { path } }); + default: + throw new Error(`unsupported database dialect: ${dialect}`); + } +} + +export { runMigrations } from './migrate.js'; diff --git a/server/src/lib/db/migrate.js b/server/src/lib/db/migrate.js @@ -0,0 +1,211 @@ +// src/lib/db/migrate.js - forward-only SQL migration runner. + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +import { RULES } from './query.js'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +export const MIGRATIONS_ROOT = path.resolve(HERE, '../../../migrations'); + +const DDL = { + sqlite: ` + CREATE TABLE IF NOT EXISTS _migrations ( + filename TEXT PRIMARY KEY, + checksum TEXT NOT NULL, + executed_at INTEGER NOT NULL + ) + `, + mysql: ` + CREATE TABLE IF NOT EXISTS _migrations ( + filename VARCHAR(255) NOT NULL PRIMARY KEY, + checksum CHAR(64) NOT NULL, + executed_at BIGINT NOT NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + `, + postgres: ` + CREATE TABLE IF NOT EXISTS _migrations ( + filename TEXT NOT NULL PRIMARY KEY, + checksum CHAR(64) NOT NULL, + executed_at BIGINT NOT NULL + ) + `, +}; + +// Splits a SQL file into statements on semicolons, while respecting string +// literals, quoted identifiers and comments. Splitting on a bare semicolon, +// as the prototype did, corrupts any statement containing one inside a +// literal. Lexing rules are per dialect, shared with the query compiler. +export function splitStatements(sql, dialect = 'sqlite') { + const rules = RULES[dialect]; + if (!rules) throw new Error(`unknown dialect: ${dialect}`); + + const out = []; + let cur = ''; + let i = 0; + + while (i < sql.length) { + const c = sql[i]; + const next = sql[i + 1]; + + if ((c === '-' && next === '-') || (rules.hash && c === '#')) { + const nl = sql.indexOf('\n', i); + if (nl === -1) break; + cur += '\n'; + i = nl + 1; + continue; + } + + if (c === '/' && next === '*') { + let depth = 1; + let j = i + 2; + while (j < sql.length && depth > 0) { + if (rules.nestedBlock && sql[j] === '/' && sql[j + 1] === '*') { + depth += 1; + j += 2; + } else if (sql[j] === '*' && sql[j + 1] === '/') { + depth -= 1; + j += 2; + } else { + j += 1; + } + } + cur += ' '; + i = j; + continue; + } + + if (c === "'" || c === '"' || (rules.backtick && c === '`')) { + const quote = c; + cur += c; + i += 1; + while (i < sql.length) { + // Backslash escapes apply to MySQL string literals, not to backtick + // quoted identifiers. + if (rules.backslash && quote !== '`' && sql[i] === '\\') { + cur += sql[i] + (sql[i + 1] ?? ''); + i += 2; + continue; + } + if (sql[i] === quote) { + // A doubled quote is a literal quote, not a terminator. + if (sql[i + 1] === quote) { + cur += quote + quote; + i += 2; + continue; + } + cur += quote; + i += 1; + break; + } + cur += sql[i]; + i += 1; + } + continue; + } + + // Dollar quoted bodies routinely contain semicolons. + if (rules.dollar && c === '$') { + const m = /^\$([A-Za-z_][A-Za-z0-9_]*)?\$/.exec(sql.slice(i)); + if (m) { + const tag = m[0]; + const end = sql.indexOf(tag, i + tag.length); + const stop = end === -1 ? sql.length : end + tag.length; + cur += sql.slice(i, stop); + i = stop; + continue; + } + } + + if (c === ';') { + if (cur.trim()) out.push(cur.trim()); + cur = ''; + i += 1; + continue; + } + + cur += c; + i += 1; + } + + if (cur.trim()) out.push(cur.trim()); + return out; +} + +function checksum(sql) { + // Normalize line endings so a CRLF checkout does not invalidate history. + return crypto.createHash('sha256').update(sql.replace(/\r\n/g, '\n')).digest('hex'); +} + +export async function runMigrations(db, options = {}) { + const dir = options.dir || path.join(MIGRATIONS_ROOT, db.dialect); + const namespace = options.namespace || path.basename(dir); + const log = options.logger || ((m) => console.log(`[migrate] ${m}`)); + + await db.exec(DDL[db.dialect]); + + const appliedRows = await db.all('SELECT filename, checksum FROM _migrations'); + const applied = new Map(); + for (const row of appliedRows) { + const prefix = `${namespace}/`; + if (!row.filename.startsWith(prefix)) continue; + applied.set(row.filename.slice(prefix.length), row.checksum); + } + + let files; + try { + files = (await fs.readdir(dir)).filter((f) => f.endsWith('.sql')).sort(); + } catch (e) { + if (e.code === 'ENOENT') throw new Error(`no migrations directory for dialect ${db.dialect}: ${dir}`); + throw e; + } + + const pending = []; + for (const file of files) { + const sql = await fs.readFile(path.join(dir, file), 'utf8'); + const sum = checksum(sql); + const prev = applied.get(file); + if (prev === undefined) { + pending.push({ file, sql, sum }); + continue; + } + if (prev !== sum) { + throw new Error( + `migration ${file} was modified after it was applied\n` + + ` recorded: ${prev}\n` + + ` on disk: ${sum}\n` + + 'Add a new migration instead of editing an applied one.' + ); + } + } + + for (const name of applied.keys()) { + if (!files.includes(name)) { + throw new Error(`migration ${name} is recorded as applied but is missing from ${dir}`); + } + } + + if (pending.length === 0) { + log(`up to date (${files.length} applied)`); + return { applied: [], total: files.length }; + } + + const done = []; + for (const { file, sql, sum } of pending) { + log(`applying ${file}`); + const statements = splitStatements(sql, db.dialect); + if (statements.length === 0) throw new Error(`migration ${file} contains no statements`); + await db.transaction(async (tx) => { + for (const stmt of statements) await tx.exec(stmt); + await tx.run( + 'INSERT INTO _migrations (filename, checksum, executed_at) VALUES ({file}, {sum}, {at})', + { file: `${namespace}/${file}`, sum, at: Date.now() } + ); + }); + done.push(file); + } + + log(`applied ${done.length} migration(s)`); + return { applied: done, total: files.length }; +} diff --git a/server/src/lib/db/params.js b/server/src/lib/db/params.js @@ -0,0 +1,31 @@ +// src/lib/db/params.js - bind value normalization. +// +// node:sqlite rejects booleans, undefined and Date objects. Timestamps are +// epoch milliseconds, so Date values are flattened here. + +export function normalizeValue(v, key) { + if (v === undefined || v === null) return null; + if (typeof v === 'boolean') return v ? 1 : 0; + if (v instanceof Date) { + if (Number.isNaN(v.getTime())) { + throw new Error(`bind parameter ${key ? `{${key}} ` : ''}is an invalid Date`); + } + return v.getTime(); + } + if (typeof v === 'bigint') { + if (v > BigInt(Number.MAX_SAFE_INTEGER) || v < BigInt(Number.MIN_SAFE_INTEGER)) { + throw new Error(`bind parameter ${key ? `{${key}} ` : ''}exceeds the safe integer range: ${v}`); + } + return Number(v); + } + if (typeof v === 'number' || typeof v === 'string') return v; + if (Buffer.isBuffer(v) || v instanceof Uint8Array) return v; + + // Objects and arrays are almost always a forgotten JSON.stringify, or a + // dotted key that should have addressed a leaf. Both are worth catching. + throw new Error( + `bind parameter ${key ? `{${key}} ` : ''}has unsupported type ${ + Array.isArray(v) ? 'array' : typeof v + }; serialize it first, or address a nested value with a dotted key` + ); +} diff --git a/server/src/lib/db/query.js b/server/src/lib/db/query.js @@ -0,0 +1,209 @@ +// src/lib/db/query.js - named placeholder compilation. +// +// {name} compiles to ? for sqlite and mysql, $n for postgres. Dotted names +// address nested values. Braces inside literals, identifiers and comments are +// left untouched; {{name}} emits a literal {name}. + +import { normalizeValue } from './params.js'; + +const NAME = /^\{([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)*)\}/; + +// Per dialect lexing rules. Everything here is about deciding what to skip. +export const RULES = { + sqlite: { hash: false, backtick: true, backslash: false, dollar: false, nestedBlock: false }, + mysql: { hash: true, backtick: true, backslash: true, dollar: false, nestedBlock: false }, + postgres: { hash: false, backtick: false, backslash: false, dollar: true, nestedBlock: true }, +}; + +function placeholder(dialect, index) { + return dialect === 'postgres' ? `$${index}` : '?'; +} + +export function compileQuery(sql, dialect) { + const rules = RULES[dialect]; + if (!rules) throw new Error(`unknown dialect: ${dialect}`); + + let out = ''; + const keys = []; + let i = 0; + + while (i < sql.length) { + const c = sql[i]; + const next = sql[i + 1]; + + if ((c === '-' && next === '-') || (rules.hash && c === '#')) { + const nl = sql.indexOf('\n', i); + const end = nl === -1 ? sql.length : nl; + out += sql.slice(i, end); + i = end; + continue; + } + + if (c === '/' && next === '*') { + let depth = 1; + let j = i + 2; + while (j < sql.length && depth > 0) { + if (rules.nestedBlock && sql[j] === '/' && sql[j + 1] === '*') { + depth += 1; + j += 2; + } else if (sql[j] === '*' && sql[j + 1] === '/') { + depth -= 1; + j += 2; + } else { + j += 1; + } + } + out += sql.slice(i, j); + i = j; + continue; + } + + if (c === "'" || c === '"' || (rules.backtick && c === '`')) { + const quote = c; + let j = i + 1; + while (j < sql.length) { + if (rules.backslash && quote !== '`' && sql[j] === '\\') { + j += 2; + continue; + } + if (sql[j] === quote) { + // A doubled quote is an escaped quote, not a terminator. + if (sql[j + 1] === quote) { + j += 2; + continue; + } + j += 1; + break; + } + j += 1; + } + out += sql.slice(i, j); + i = j; + continue; + } + + if (rules.dollar && c === '$') { + const m = /^\$([A-Za-z_][A-Za-z0-9_]*)?\$/.exec(sql.slice(i)); + if (m) { + const tag = m[0]; + const end = sql.indexOf(tag, i + tag.length); + const stop = end === -1 ? sql.length : end + tag.length; + out += sql.slice(i, stop); + i = stop; + continue; + } + } + + // Doubling escapes a brace, so {{name}} yields the literal text {name}. + if (c === '}' && next === '}') { + out += '}'; + i += 2; + continue; + } + + if (c === '{') { + if (next === '{') { + out += '{'; + i += 2; + continue; + } + const m = NAME.exec(sql.slice(i)); + if (!m) { + throw new Error( + `malformed bind marker at offset ${i} in query:\n ${sql.trim()}\n` + + 'Expected {name} or {outer.inner}; write {{ for a literal brace.' + ); + } + keys.push(m[1]); + out += placeholder(dialect, keys.length); + i += m[0].length; + continue; + } + + out += c; + i += 1; + } + + return { sql: out, keys }; +} + +// Compilation is pure and queries come from source code, so the set is finite. +const cache = new Map(); + +export function compileCached(sql, dialect) { + const cacheKey = `${dialect}\u0000${sql}`; + let entry = cache.get(cacheKey); + if (!entry) { + entry = compileQuery(sql, dialect); + cache.set(cacheKey, entry); + } + return entry; +} + +// Builds the body of an IN (...) clause with generated marker names, since +// the number of values is only known at runtime. +// +// const arch = inClause('arch', ['x86_64', 'aarch64']); +// db.all(`... WHERE arch IN (${arch.sql})`, { ...arch.params }); +// +// Returns a clause that matches nothing when the list is empty, which is +// the safe reading of "none of these". +export function inClause(prefix, values) { + if (!Array.isArray(values) || values.length === 0) { + return { sql: 'NULL', params: {}, empty: true }; + } + const params = {}; + const markers = values.map((value, i) => { + params[`${prefix}${i}`] = value; + return `{${prefix}${i}}`; + }); + return { sql: markers.join(', '), params, empty: false }; +} + +function resolve(params, key) { + // An exact property wins, so a pre-flattened object works unchanged. + if (params != null && Object.hasOwn(params, key)) { + return { found: true, value: params[key] }; + } + if (!key.includes('.')) return { found: false }; + + let cur = params; + for (const part of key.split('.')) { + if (cur == null || typeof cur !== 'object') return { found: false }; + if (!Object.hasOwn(cur, part)) return { found: false }; + cur = cur[part]; + } + return { found: true, value: cur }; +} + +export function bindParams(keys, params, sql) { + if (keys.length === 0) return []; + + if (params == null || typeof params !== 'object' || Array.isArray(params)) { + throw new Error( + `bind parameters must be an object, got ${Array.isArray(params) ? 'an array' : typeof params}\n` + + ` query: ${sql.trim().split('\n')[0]}\n` + + ` expected keys: ${[...new Set(keys)].join(', ')}` + ); + } + + const missing = []; + const values = keys.map((key) => { + const hit = resolve(params, key); + if (!hit.found) { + missing.push(key); + return null; + } + return normalizeValue(hit.value, key); + }); + + if (missing.length > 0) { + throw new Error( + `missing bind parameter(s): ${[...new Set(missing)].join(', ')}\n` + + ` query: ${sql.trim().split('\n')[0]}\n` + + ` supplied: ${Object.keys(params).join(', ') || '(none)'}` + ); + } + + return values; +} diff --git a/server/src/lib/db/sqlite.js b/server/src/lib/db/sqlite.js @@ -0,0 +1,119 @@ +// src/lib/db/sqlite.js - sqlite driver built on node:sqlite. +// +// node:sqlite is synchronous. The methods are async so callers use one +// interface for every driver. + +import { DatabaseSync } from 'node:sqlite'; +import { compileCached, bindParams } from './query.js'; + +export function openSqlite(cfg) { + const db = new DatabaseSync(cfg.database.path); + + // WAL keeps readers from blocking the writer. + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA busy_timeout = 5000'); + db.exec('PRAGMA synchronous = NORMAL'); + + const statements = new Map(); + + function prepare(sql) { + const compiled = compileCached(sql, 'sqlite'); + let stmt = statements.get(compiled.sql); + if (!stmt) { + stmt = db.prepare(compiled.sql); + statements.set(compiled.sql, stmt); + } + return { stmt, keys: compiled.keys }; + } + + function fail(e, sql) { + const err = new Error(`sqlite: ${e.message}\n sql: ${sql.trim().split('\n')[0]}`); + err.cause = e; + err.code = e.code; + throw err; + } + + // node:sqlite returns null-prototype objects. + const toPlain = (row) => (row === undefined ? undefined : { ...row }); + + const api = { + dialect: 'sqlite', + + async all(sql, params = {}) { + const { stmt, keys } = prepare(sql); + const values = bindParams(keys, params, sql); + try { + return stmt.all(...values).map(toPlain); + } catch (e) { + fail(e, sql); + } + }, + + async get(sql, params = {}) { + const { stmt, keys } = prepare(sql); + const values = bindParams(keys, params, sql); + try { + return toPlain(stmt.get(...values)); + } catch (e) { + fail(e, sql); + } + }, + + async run(sql, params = {}) { + const { stmt, keys } = prepare(sql); + const values = bindParams(keys, params, sql); + try { + const r = stmt.run(...values); + return { + changes: Number(r.changes), + lastInsertId: r.lastInsertRowid === undefined ? null : Number(r.lastInsertRowid), + }; + } catch (e) { + fail(e, sql); + } + }, + + // Raw DDL, no bind markers are interpreted. + async exec(sql) { + try { + db.exec(sql); + } catch (e) { + fail(e, sql); + } + }, + + async close() { + statements.clear(); + db.close(); + }, + }; + + // Nesting uses savepoints so that a helper which opens a transaction can be + // composed inside a larger one. + let depth = 0; + api.transaction = async function transaction(fn) { + const name = `sp_${depth}`; + if (depth === 0) db.exec('BEGIN IMMEDIATE'); + else db.exec(`SAVEPOINT ${name}`); + depth += 1; + try { + const result = await fn(api); + depth -= 1; + if (depth === 0) db.exec('COMMIT'); + else db.exec(`RELEASE ${name}`); + return result; + } catch (e) { + depth -= 1; + try { + if (depth === 0) db.exec('ROLLBACK'); + else db.exec(`ROLLBACK TO ${name}`); + } catch { + // The original error is more useful than a rollback failure. + } + throw e; + } + }; + + return api; +} diff --git a/server/src/lib/http/http-error.js b/server/src/lib/http/http-error.js @@ -0,0 +1,18 @@ +import { HttpResponse } from './http-response.js'; + +export class HttpError extends Error { + constructor(status, code, message) { + super(message); + this.status = status; + this.code = code; + } + + toResponse() { + return new HttpResponse(this.status, { + error: { + code: this.code, + message: this.message, + }, + }); + } +} diff --git a/server/src/lib/http/http-ok.js b/server/src/lib/http/http-ok.js @@ -0,0 +1,7 @@ +import { HttpResponse } from './http-response.js'; + +export class HttpOk extends HttpResponse { + constructor(body, headers = {}) { + super(200, body, headers); + } +} diff --git a/server/src/lib/http/http-response.js b/server/src/lib/http/http-response.js @@ -0,0 +1,22 @@ +export class HttpResponse { + constructor(status, body, headers = {}) { + this.status = status; + this.body = body; + this.headers = headers; + } + + send(res) { + if (this.body === undefined) { + res.writeHead(this.status, this.headers); + res.end(); + return; + } + + const isText = typeof this.body === 'string'; + res.writeHead(this.status, { + 'content-type': isText ? 'text/plain; charset=utf-8' : 'application/json; charset=utf-8', + ...this.headers, + }); + res.end(isText ? this.body : JSON.stringify(this.body)); + } +} diff --git a/server/src/lib/login.js b/server/src/lib/login.js @@ -0,0 +1,54 @@ +import { HttpError } from './http/http-error.js'; + +export function normalizeIdentifier(identifier) { + const value = String(identifier || '').trim(); + if (!value) { + throw new HttpError(400, 'invalid_request', 'An email address or username is required'); + } + if (value.length > 254) { + throw new HttpError(400, 'invalid_request', 'The identifier is too long'); + } + + const at = value.lastIndexOf('@'); + const isEmail = at > 0 && at < value.length - 1; + const normalized = value.toLowerCase(); + + return { + value, + normalized, + isEmail, + domain: isEmail ? normalized.slice(at + 1) : null, + }; +} + +export async function resolveLogin(db, identifier) { + const parsed = normalizeIdentifier(identifier); + if (!parsed.isEmail) { + return { next: 'password' }; + } + + const provider = await db.get( + `SELECT p.slug, p.display_name, p.enabled + FROM email_domains d + JOIN identity_providers p ON p.id = d.provider_id + WHERE d.domain = {domain} + LIMIT 1`, + { domain: parsed.domain } + ); + + if (!provider) { + return { next: 'password' }; + } + + if (!provider.enabled) { + throw new HttpError(409, 'provider_disabled', 'The identity provider for this domain is unavailable'); + } + + return { + next: 'oidc', + provider: { + slug: provider.slug, + display_name: provider.display_name, + }, + }; +} diff --git a/server/src/lib/realm.js b/server/src/lib/realm.js @@ -0,0 +1,307 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { openDatabase, runMigrations } from './db/index.js'; +import { MIGRATIONS_ROOT } from './db/migrate.js'; +import { HttpError } from './http/http-error.js'; + +let systemDb = null; +let dialect = 'sqlite'; +let realmsPath = null; +const realmDbs = new Map(); + +function migrationDir(dbDialect, type) { + return path.join(MIGRATIONS_ROOT, dbDialect, type); +} + +async function migrateRealm(db) { + return runMigrations(db, { + dir: migrationDir(db.dialect, 'realm'), + namespace: 'realm', + }); +} + +async function migrateSystem(db) { + await migrateRealm(db); + return runMigrations(db, { + dir: migrationDir(db.dialect, 'system'), + namespace: 'system', + }); +} + +function parseBranding(raw) { + try { + const parsed = JSON.parse(raw || '{}'); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; + } catch { + return {}; + } +} + +async function ensureRole(db, slug, name, now) { + const existing = await db.get('SELECT id FROM roles WHERE slug = {slug}', { slug }); + if (existing) return; + await db.run( + 'INSERT INTO roles (id, slug, name, created_at) VALUES ({id}, {slug}, {name}, {createdAt})', + { id: crypto.randomUUID(), slug, name, createdAt: now } + ); +} + +async function ensureAdminClient(db, now) { + const existing = await db.get('SELECT id FROM oidc_clients WHERE client_id = {clientId}', { + clientId: 'admin-ui', + }); + if (existing) return; + await db.run( + `INSERT INTO oidc_clients + (id, client_id, name, redirect_uris, scopes, enabled, created_at, updated_at) + VALUES + ({id}, {clientId}, {name}, {redirectUris}, {scopes}, 1, {createdAt}, {updatedAt})`, + { + id: crypto.randomUUID(), + clientId: 'admin-ui', + name: 'Admin UI', + redirectUris: '[]', + scopes: 'openid profile email', + createdAt: now, + updatedAt: now, + } + ); +} + +async function seedRealmDefaults(db, kind) { + const now = Date.now(); + await ensureRole(db, 'realm-admin', 'Realm administrator', now); + if (kind === 'system') { + await ensureRole(db, 'system-admin', 'System administrator', now); + } + await ensureAdminClient(db, now); +} + +async function writeRealmSettings(db, settings) { + const now = Date.now(); + await db.run( + `INSERT INTO realm_settings + (id, slug, display_name, kind, status, branding, created_at, updated_at) + VALUES + (1, {slug}, {displayName}, {kind}, 'active', {branding}, {createdAt}, {updatedAt})`, + { + slug: settings.slug, + displayName: settings.displayName, + kind: settings.kind || 'realm', + branding: JSON.stringify(settings.branding || {}), + createdAt: now, + updatedAt: now, + } + ); +} + +async function ensureSystemRealm() { + const settings = await systemDb.get('SELECT id FROM realm_settings WHERE id = 1'); + if (!settings) { + await writeRealmSettings(systemDb, { + slug: 'system', + displayName: 'System', + kind: 'system', + }); + } + + const registry = await systemDb.get('SELECT id FROM realms WHERE id = {id}', { id: 'system' }); + if (!registry) { + await systemDb.run( + 'INSERT INTO realms (id, slug, db_file, created_at) VALUES ({id}, {slug}, NULL, {createdAt})', + { id: 'system', slug: 'system', createdAt: Date.now() } + ); + } + + await seedRealmDefaults(systemDb, 'system'); + + const domains = await systemDb.get('SELECT COUNT(*) AS count FROM realm_domains'); + if (Number(domains.count) === 0) { + const now = Date.now(); + for (const host of ['localhost', '127.0.0.1', '[::1]']) { + await systemDb.run( + 'INSERT INTO realm_domains (host, realm_id, created_at) VALUES ({host}, {realmId}, {createdAt})', + { host, realmId: 'system', createdAt: now } + ); + } + } +} + +export function normalizeHost(host) { + let value = String(host || '').trim().toLowerCase(); + if (!value) return ''; + if (value.startsWith('[')) { + const end = value.indexOf(']'); + if (end !== -1) value = value.slice(0, end + 1); + } else { + const colon = value.indexOf(':'); + if (colon !== -1) value = value.slice(0, colon); + } + return value.replace(/\.$/, ''); +} + +export async function initRealms(cfg) { + await closeRealms(); + + dialect = cfg.database.dialect; + realmsPath = path.resolve(cfg.database.realms); + const systemPath = path.resolve(cfg.database.path); + + fs.mkdirSync(realmsPath, { recursive: true }); + fs.mkdirSync(path.dirname(systemPath), { recursive: true }); + + systemDb = await openDatabase({ dialect, path: systemPath }); + await migrateSystem(systemDb); + await ensureSystemRealm(); +} + +export async function closeRealms() { + for (const db of realmDbs.values()) { + await db.close(); + } + realmDbs.clear(); + + if (systemDb) { + await systemDb.close(); + systemDb = null; + } +} + +export function getSystemDatabase() { + if (!systemDb) throw new Error('realm registry is not initialized'); + return systemDb; +} + +export async function openRealmDatabase(realm) { + if (realm.id === 'system') { + if (!systemDb) throw new Error('realm registry is not initialized'); + return systemDb; + } + + const cached = realmDbs.get(realm.id); + if (cached) return cached; + + const fullPath = path.resolve(realmsPath, realm.db_file); + if (path.dirname(fullPath) !== realmsPath) { + throw new HttpError(404, 'unknown_realm', 'Unknown realm'); + } + if (!fs.existsSync(fullPath)) { + throw new HttpError(404, 'unknown_realm', 'Unknown realm'); + } + + const db = await openDatabase({ dialect, path: fullPath }); + await migrateRealm(db); + await seedRealmDefaults(db, 'realm'); + realmDbs.set(realm.id, db); + return db; +} + +export async function realmMetadata(realm) { + const db = await openRealmDatabase(realm); + const settings = await db.get( + 'SELECT slug, display_name, status, branding FROM realm_settings WHERE id = 1' + ); + + if (!settings || settings.status !== 'active') { + throw new HttpError(404, 'unknown_realm', 'Unknown realm'); + } + + return { + slug: settings.slug, + display_name: settings.display_name, + branding: parseBranding(settings.branding), + }; +} + +export async function resolveRealm({ host, slug } = {}) { + if (!systemDb) throw new Error('realm registry is not initialized'); + + const normalizedHost = normalizeHost(host); + if (normalizedHost) { + const byHost = await systemDb.get( + `SELECT r.id, r.slug, r.db_file + FROM realm_domains d + JOIN realms r ON r.id = d.realm_id + WHERE d.host = {host}`, + { host: normalizedHost } + ); + if (byHost) { + if (slug && slug !== byHost.slug) { + throw new HttpError(404, 'unknown_realm', 'Unknown realm'); + } + return byHost; + } + } + + if (slug) { + const bySlug = await systemDb.get( + 'SELECT id, slug, db_file FROM realms WHERE slug = {slug}', + { slug } + ); + if (bySlug) return bySlug; + } + + throw new HttpError(404, 'unknown_realm', 'Unknown realm'); +} + +export async function createRealm({ slug, displayName, branding = {}, hosts = [] }) { + if (!systemDb) throw new Error('realm registry is not initialized'); + if (!/^[a-z0-9][a-z0-9-]*$/.test(slug || '')) { + throw new Error(`invalid realm slug: ${JSON.stringify(slug)}`); + } + + const existing = await systemDb.get('SELECT id FROM realms WHERE slug = {slug}', { slug }); + if (existing) throw new Error(`realm already exists: ${slug}`); + + const id = crypto.randomUUID(); + const dbFile = `${id}.sqlite`; + const fullPath = path.join(realmsPath, dbFile); + + const db = await openDatabase({ dialect, path: fullPath }); + try { + await migrateRealm(db); + await writeRealmSettings(db, { slug, displayName, kind: 'realm', branding }); + await seedRealmDefaults(db, 'realm'); + } catch (e) { + await db.close(); + fs.rmSync(fullPath, { force: true }); + throw e; + } + + try { + await systemDb.run( + 'INSERT INTO realms (id, slug, db_file, created_at) VALUES ({id}, {slug}, {dbFile}, {createdAt})', + { id, slug, dbFile, createdAt: Date.now() } + ); + + for (const host of hosts) { + await addRealmHost(id, host); + } + } catch (e) { + await systemDb.run('DELETE FROM realms WHERE id = {id}', { id }); + await db.close(); + fs.rmSync(fullPath, { force: true }); + throw e; + } + + realmDbs.set(id, db); + return { id, slug, db_file: dbFile }; +} + +export async function addRealmHost(realmId, host) { + if (!systemDb) throw new Error('realm registry is not initialized'); + const normalized = normalizeHost(host); + if (!normalized || normalized !== String(host).trim().toLowerCase()) { + throw new Error(`invalid realm hostname: ${JSON.stringify(host)}`); + } + await systemDb.run( + 'INSERT INTO realm_domains (host, realm_id, created_at) VALUES ({host}, {realmId}, {createdAt})', + { host: normalized, realmId, createdAt: Date.now() } + ); +} + +export async function findRealmBySlug(slug) { + if (!systemDb) throw new Error('realm registry is not initialized'); + return systemDb.get('SELECT id, slug, db_file FROM realms WHERE slug = {slug}', { slug }); +} diff --git a/server/test/config.test.js b/server/test/config.test.js @@ -0,0 +1,132 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { loadConfig } from '../src/lib/config.js'; + +async function withEnv(env, fn) { + const saved = {}; + for (const key of Object.keys(process.env)) { + if (key.startsWith('AUTH_')) { + saved[key] = process.env[key]; + delete process.env[key]; + } + } + Object.assign(process.env, env); + try { + return await fn(); + } finally { + for (const key of Object.keys(process.env)) { + if (key.startsWith('AUTH_')) delete process.env[key]; + } + Object.assign(process.env, saved); + } +} + +async function inTempDir(fn) { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'auth-cfg-')); + const cwd = process.cwd(); + process.chdir(dir); + try { + return await fn(); + } finally { + process.chdir(cwd); + await fs.rm(dir, { recursive: true, force: true }); + } +} + +test('sqlite defaults load without any configuration', async () => { + await inTempDir(() => + withEnv({}, () => { + const cfg = loadConfig(); + assert.equal(cfg.database.dialect, 'sqlite'); + assert.equal(cfg.database.path, './data/system.sqlite'); + assert.equal(cfg.database.realms, './data/realms'); + }) + ); +}); + +test('an explicit null database path is rejected', async () => { + await inTempDir(async () => { + await fs.writeFile('auth.yaml', 'database:\n path: null\n'); + await withEnv({}, () => { + assert.throws(() => loadConfig(), /database\.path/); + }); + }); +}); + +test('sqlite configuration loads with both paths', async () => { + await inTempDir(() => + withEnv( + { + AUTH_DATABASE_DIALECT: 'sqlite', + AUTH_DATABASE_PATH: './data/system.sqlite', + AUTH_REALMS_PATH: './data/realms', + }, + () => { + const cfg = loadConfig(); + assert.equal(cfg.database.dialect, 'sqlite'); + assert.equal(cfg.database.path, './data/system.sqlite'); + assert.equal(cfg.database.realms, './data/realms'); + assert.throws(() => { + cfg.server.port = 1; + }, TypeError); + } + ) + ); +}); + +test('an unsupported dialect is rejected', async () => { + await inTempDir(() => + withEnv( + { + AUTH_DATABASE_DIALECT: 'mysql', + AUTH_DATABASE_PATH: './data/system.sqlite', + AUTH_REALMS_PATH: './data/realms', + }, + () => { + assert.throws(() => loadConfig(), /database\.dialect/); + } + ) + ); +}); + +test('a non-integer port names the environment variable', async () => { + await inTempDir(() => + withEnv( + { + AUTH_DATABASE_PATH: './data/system.sqlite', + AUTH_REALMS_PATH: './data/realms', + AUTH_PORT: 'http', + }, + () => { + assert.throws(() => loadConfig(), /AUTH_PORT/); + } + ) + ); +}); + +test('environment variables override the YAML file', async () => { + await inTempDir(async () => { + await fs.writeFile( + 'auth.yaml', + [ + 'server:', + ' port: 9999', + 'database:', + ' dialect: sqlite', + ' path: ./file/system.sqlite', + ' realms: ./file/realms', + '', + ].join('\n') + ); + + await withEnv({ AUTH_PORT: '7777', AUTH_DATABASE_PATH: './env/system.sqlite' }, () => { + const cfg = loadConfig(); + assert.equal(cfg.server.port, 7777); + assert.equal(cfg.database.path, './env/system.sqlite'); + assert.equal(cfg.database.realms, './file/realms'); + }); + }); +}); diff --git a/server/test/realm.test.js b/server/test/realm.test.js @@ -0,0 +1,95 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { resolveLogin } from '../src/lib/login.js'; +import { + closeRealms, + createRealm, + initRealms, + openRealmDatabase, + realmMetadata, + resolveRealm, +} from '../src/lib/realm.js'; + +async function tempConfig() { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'auth-realm-')); + return { + dir, + cfg: { + database: { + dialect: 'sqlite', + path: path.join(dir, 'system.sqlite'), + realms: path.join(dir, 'realms'), + }, + }, + }; +} + +test('realm resolution prefers hostname and rejects a mismatched slug', async () => { + const { dir, cfg } = await tempConfig(); + try { + await initRealms(cfg); + await createRealm({ slug: 'acme', displayName: 'Acme', hosts: ['acme.localhost'] }); + + const byHost = await resolveRealm({ host: 'acme.localhost' }); + assert.equal(byHost.slug, 'acme'); + + const bySlug = await resolveRealm({ host: 'main.test:3000', slug: 'acme' }); + assert.equal(bySlug.slug, 'acme'); + + await assert.rejects( + () => resolveRealm({ host: 'acme.localhost', slug: 'system' }), + /Unknown realm/ + ); + + await assert.rejects(() => resolveRealm({ host: 'main.test:3000', slug: 'nope' }), /Unknown realm/); + + const metadata = await realmMetadata(byHost); + assert.equal(metadata.display_name, 'Acme'); + assert.deepEqual(metadata.branding, {}); + } finally { + await closeRealms(); + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('identifier resolution selects password, upstream provider or a disabled provider', async () => { + const { dir, cfg } = await tempConfig(); + try { + await initRealms(cfg); + const realm = await createRealm({ slug: 'acme', displayName: 'Acme' }); + const db = await openRealmDatabase(realm); + + const local = await resolveLogin(db, 'alice@local.test'); + assert.equal(local.next, 'password'); + const username = await resolveLogin(db, 'alice'); + assert.equal(username.next, 'password'); + + const providerId = crypto.randomUUID(); + const now = Date.now(); + await db.run( + `INSERT INTO identity_providers + (id, slug, display_name, issuer, client_id, client_secret, scopes, enabled, created_at, updated_at) + VALUES + ({id}, 'example-sso', 'Example SSO', 'https://idp.example.com', 'demo', NULL, 'openid email profile', 1, {now}, {now})`, + { id: providerId, now } + ); + await db.run( + 'INSERT INTO email_domains (domain, provider_id, created_at) VALUES ({domain}, {providerId}, {now})', + { domain: 'example.com', providerId, now } + ); + + const upstream = await resolveLogin(db, 'someone@example.com'); + assert.equal(upstream.next, 'oidc'); + assert.equal(upstream.provider.display_name, 'Example SSO'); + + await db.run('UPDATE identity_providers SET enabled = 0 WHERE id = {id}', { id: providerId }); + await assert.rejects(() => resolveLogin(db, 'someone@example.com'), /unavailable/); + } finally { + await closeRealms(); + await fs.rm(dir, { recursive: true, force: true }); + } +}); diff --git a/server/web/esbuild.cjs b/server/web/esbuild.cjs @@ -76,17 +76,11 @@ esbuild <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=0"> - <title>conductor</title> - <link rel="icon" type="image/svg+xml" href="assets/icon.svg"> - <link rel="icon" type="image/png" sizes="32x32" href="assets/favicon-32.png"> - <link rel="icon" type="image/png" sizes="32x32" href="assets/favicon-32-dark.png" media="(prefers-color-scheme: dark)"> - <link rel="apple-touch-icon" href="assets/apple-touch-icon.png"> - <link rel="manifest" href="assets/manifest.json"/> - <!-- <link rel="stylesheet" href="/assets/fonts/karla/karla.css"/> --> - ${styles.map(name => `<link rel="stylesheet" href="${name}"/>`).join('\n ')} + <title>auth</title> + ${styles.map(name => `<link rel="stylesheet" href="/${name}"/>`).join('\n ')} </head> <body> - ${buildList.map(name => `<script src="${name}" defer></script>`).join('\n ')} + ${buildList.map(name => `<script src="/${name}" defer></script>`).join('\n ')} </body> </html> `); diff --git a/server/web/src/assets/global.css b/server/web/src/assets/global.css @@ -1,3 +1,193 @@ :root { - font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; + --realm-primary: #25A; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; +} + +.auth-page { + min-height: 100vh; + display: flex; + justify-content: center; + align-items: flex-start; + padding: 12vh 1.5rem 3rem; + background: #fff; +} + +.auth-card { + width: min(24rem, 100%); + padding: 2rem; + background: #fff; + border-radius: 0; +} + +.auth-logo { + max-width: 8rem; + margin-bottom: 1rem; +} + +.auth-card h1 { + margin: 0 0 1.5rem; + font-size: 1.5rem; + font-weight: 400; +} + +.auth-field { + position: relative; + padding-top: 1rem; +} + +.auth-input { + width: 100%; + padding: 0.5rem 0; + border: 0; + border-bottom: 1pt solid rgba(0, 0, 0, 0.42); + border-radius: 0; + background: transparent; + color: inherit; + font: inherit; + font-size: 1rem; + line-height: 1.5; + outline: none; +} + +.auth-input:focus { + border-bottom-color: var(--realm-primary); +} + +.auth-field label { + position: absolute; + top: 1.5em; + left: 0; + font-size: 1rem; + line-height: 1.5; + color: rgba(0, 0, 0, 0.6); + pointer-events: none; + transform-origin: left top; + transition: top 0.15s ease, font-size 0.15s ease, color 0.15s ease, opacity 0.15s ease; +} + +.auth-input:focus + label, +.auth-input:not(:placeholder-shown) + label { + top: 0; + font-size: 0.85em; + font-weight: 500; + color: inherit; + opacity: 0.8; +} + +.auth-identifier { + margin: 0 0 0.5rem; + color: rgba(0, 0, 0, 0.6); + font-size: 0.875rem; +} + +.auth-note { + margin: 0 0 1rem; + color: rgba(0, 0, 0, 0.6); + font-size: 0.875rem; + line-height: 1.5; +} + +.auth-error { + margin: 1rem 0 0; + color: #d93025; + font-size: 0.875rem; + line-height: 1.5; +} + +.auth-actions { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 1rem; + margin-top: 1.5rem; +} + +.auth-button { + padding: 0.75rem 1.5rem; + border: 0; + border-radius: 0; + background: var(--realm-primary); + color: #fff; + font: inherit; + font-size: 0.875rem; + letter-spacing: 0.05em; + text-transform: uppercase; + cursor: pointer; +} + +.auth-button:disabled { + opacity: 0.5; + cursor: default; +} + +.auth-link { + padding: 0.5rem 0; + border: 0; + background: none; + color: var(--realm-primary); + font: inherit; + font-size: 0.875rem; + cursor: pointer; +} + +@media (prefers-color-scheme: dark) { + .auth-page { + background-color: #222; + } + + .auth-card { + background-color: #222; + color: #fff; + } + + .auth-input { + border-bottom-color: rgba(255, 255, 255, 0.42); + color: #fff; + } + + .auth-field label, + .auth-identifier, + .auth-note { + color: rgba(255, 255, 255, 0.6); + } + + .auth-error { + color: #f28b82; + } +} + +@media (min-width: 35em) { + .auth-page { + background-color: #fff; + background-image: url('/assets/hex-grid-light.svg'); + background-position: center; + background-repeat: no-repeat; + background-size: cover; + } + + .auth-card { + box-shadow: 0 0.25em 0.5em #0008; + } +} + +@media (min-width: 35em) and (prefers-color-scheme: dark) { + .auth-page { + background-color: #222; + background-image: url('/assets/hex-grid-dark.svg'); + background-position: center; + background-repeat: no-repeat; + background-size: cover; + } } diff --git a/server/web/src/assets/hex-grid-dark.svg b/server/web/src/assets/hex-grid-dark.svg @@ -0,0 +1,339 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="576" viewBox="0 0 1024 576"> +<rect width="100%" height="100%" fill="#000"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 25.6 -44.341 L 51.2 -29.56 L 51.2 0 L 25.6 14.78 L 0 0 L 0 -29.56 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 76.8 -44.341 L 102.4 -29.56 L 102.4 0 L 76.8 14.78 L 51.2 0 L 51.2 -29.56 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.76" d="M 128 -44.341 L 153.6 -29.56 L 153.6 0 L 128 14.78 L 102.4 0 L 102.4 -29.56 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 179.2 -44.341 L 204.8 -29.56 L 204.8 0 L 179.2 14.78 L 153.6 0 L 153.6 -29.56 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.46" d="M 230.4 -44.341 L 256 -29.56 L 256 0 L 230.4 14.78 L 204.8 0 L 204.8 -29.56 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.31" d="M 281.6 -44.341 L 307.2 -29.56 L 307.2 0 L 281.6 14.78 L 256 0 L 256 -29.56 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 332.8 -44.341 L 358.4 -29.56 L 358.4 0 L 332.8 14.78 L 307.2 0 L 307.2 -29.56 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.39" d="M 384 -44.341 L 409.6 -29.56 L 409.6 0 L 384 14.78 L 358.4 0 L 358.4 -29.56 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.32" d="M 435.2 -44.341 L 460.8 -29.56 L 460.8 0 L 435.2 14.78 L 409.6 0 L 409.6 -29.56 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 486.4 -44.341 L 512 -29.56 L 512 0 L 486.4 14.78 L 460.8 0 L 460.8 -29.56 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 537.6 -44.341 L 563.2 -29.56 L 563.2 0 L 537.6 14.78 L 512 0 L 512 -29.56 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 588.8 -44.341 L 614.4 -29.56 L 614.4 0 L 588.8 14.78 L 563.2 0 L 563.2 -29.56 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 640 -44.341 L 665.6 -29.56 L 665.6 0 L 640 14.78 L 614.4 0 L 614.4 -29.56 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.82" d="M 691.2 -44.341 L 716.8 -29.56 L 716.8 0 L 691.2 14.78 L 665.6 0 L 665.6 -29.56 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 742.4 -44.341 L 768 -29.56 L 768 0 L 742.4 14.78 L 716.8 0 L 716.8 -29.56 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.87" d="M 793.6 -44.341 L 819.2 -29.56 L 819.2 0 L 793.6 14.78 L 768 0 L 768 -29.56 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 844.8 -44.341 L 870.4 -29.56 L 870.4 0 L 844.8 14.78 L 819.2 0 L 819.2 -29.56 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 896 -44.341 L 921.6 -29.56 L 921.6 0 L 896 14.78 L 870.4 0 L 870.4 -29.56 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.42" d="M 947.2 -44.341 L 972.8 -29.56 L 972.8 0 L 947.2 14.78 L 921.6 0 L 921.6 -29.56 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 998.4 -44.341 L 1024 -29.56 L 1024 0 L 998.4 14.78 L 972.8 0 L 972.8 -29.56 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.89" d="M 1049.6 -44.341 L 1075.2 -29.56 L 1075.2 0 L 1049.6 14.78 L 1024 0 L 1024 -29.56 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 0 0 L 25.6 14.78 L 25.6 44.341 L 0 59.121 L -25.6 44.341 L -25.6 14.78 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.29" d="M 51.2 0 L 76.8 14.78 L 76.8 44.341 L 51.2 59.121 L 25.6 44.341 L 25.6 14.78 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 102.4 0 L 128 14.78 L 128 44.341 L 102.4 59.121 L 76.8 44.341 L 76.8 14.78 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.61" d="M 153.6 0 L 179.2 14.78 L 179.2 44.341 L 153.6 59.121 L 128 44.341 L 128 14.78 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 204.8 0 L 230.4 14.78 L 230.4 44.341 L 204.8 59.121 L 179.2 44.341 L 179.2 14.78 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.33" d="M 256 0 L 281.6 14.78 L 281.6 44.341 L 256 59.121 L 230.4 44.341 L 230.4 14.78 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 307.2 0 L 332.8 14.78 L 332.8 44.341 L 307.2 59.121 L 281.6 44.341 L 281.6 14.78 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 358.4 0 L 384 14.78 L 384 44.341 L 358.4 59.121 L 332.8 44.341 L 332.8 14.78 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.38" d="M 409.6 0 L 435.2 14.78 L 435.2 44.341 L 409.6 59.121 L 384 44.341 L 384 14.78 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 460.8 0 L 486.4 14.78 L 486.4 44.341 L 460.8 59.121 L 435.2 44.341 L 435.2 14.78 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 512 0 L 537.6 14.78 L 537.6 44.341 L 512 59.121 L 486.4 44.341 L 486.4 14.78 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.97" d="M 563.2 0 L 588.8 14.78 L 588.8 44.341 L 563.2 59.121 L 537.6 44.341 L 537.6 14.78 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.51" d="M 614.4 0 L 640 14.78 L 640 44.341 L 614.4 59.121 L 588.8 44.341 L 588.8 14.78 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 665.6 0 L 691.2 14.78 L 691.2 44.341 L 665.6 59.121 L 640 44.341 L 640 14.78 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 716.8 0 L 742.4 14.78 L 742.4 44.341 L 716.8 59.121 L 691.2 44.341 L 691.2 14.78 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.44" d="M 768 0 L 793.6 14.78 L 793.6 44.341 L 768 59.121 L 742.4 44.341 L 742.4 14.78 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 819.2 0 L 844.8 14.78 L 844.8 44.341 L 819.2 59.121 L 793.6 44.341 L 793.6 14.78 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.49" d="M 870.4 0 L 896 14.78 L 896 44.341 L 870.4 59.121 L 844.8 44.341 L 844.8 14.78 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.24" d="M 921.6 0 L 947.2 14.78 L 947.2 44.341 L 921.6 59.121 L 896 44.341 L 896 14.78 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 972.8 0 L 998.4 14.78 L 998.4 44.341 L 972.8 59.121 L 947.2 44.341 L 947.2 14.78 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.86" d="M 1024 0 L 1049.6 14.78 L 1049.6 44.341 L 1024 59.121 L 998.4 44.341 L 998.4 14.78 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="1" d="M 25.6 44.341 L 51.2 59.121 L 51.2 88.681 L 25.6 103.461 L 0 88.681 L 0 59.121 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.48" d="M 76.8 44.341 L 102.4 59.121 L 102.4 88.681 L 76.8 103.461 L 51.2 88.681 L 51.2 59.121 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.88" d="M 128 44.341 L 153.6 59.121 L 153.6 88.681 L 128 103.461 L 102.4 88.681 L 102.4 59.121 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 179.2 44.341 L 204.8 59.121 L 204.8 88.681 L 179.2 103.461 L 153.6 88.681 L 153.6 59.121 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 230.4 44.341 L 256 59.121 L 256 88.681 L 230.4 103.461 L 204.8 88.681 L 204.8 59.121 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 281.6 44.341 L 307.2 59.121 L 307.2 88.681 L 281.6 103.461 L 256 88.681 L 256 59.121 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.67" d="M 332.8 44.341 L 358.4 59.121 L 358.4 88.681 L 332.8 103.461 L 307.2 88.681 L 307.2 59.121 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.51" d="M 384 44.341 L 409.6 59.121 L 409.6 88.681 L 384 103.461 L 358.4 88.681 L 358.4 59.121 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.83" d="M 435.2 44.341 L 460.8 59.121 L 460.8 88.681 L 435.2 103.461 L 409.6 88.681 L 409.6 59.121 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.4" d="M 486.4 44.341 L 512 59.121 L 512 88.681 L 486.4 103.461 L 460.8 88.681 L 460.8 59.121 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 537.6 44.341 L 563.2 59.121 L 563.2 88.681 L 537.6 103.461 L 512 88.681 L 512 59.121 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.77" d="M 588.8 44.341 L 614.4 59.121 L 614.4 88.681 L 588.8 103.461 L 563.2 88.681 L 563.2 59.121 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.4" d="M 640 44.341 L 665.6 59.121 L 665.6 88.681 L 640 103.461 L 614.4 88.681 L 614.4 59.121 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.64" d="M 691.2 44.341 L 716.8 59.121 L 716.8 88.681 L 691.2 103.461 L 665.6 88.681 L 665.6 59.121 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.22" d="M 742.4 44.341 L 768 59.121 L 768 88.681 L 742.4 103.461 L 716.8 88.681 L 716.8 59.121 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.32" d="M 793.6 44.341 L 819.2 59.121 L 819.2 88.681 L 793.6 103.461 L 768 88.681 L 768 59.121 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.37" d="M 844.8 44.341 L 870.4 59.121 L 870.4 88.681 L 844.8 103.461 L 819.2 88.681 L 819.2 59.121 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 896 44.341 L 921.6 59.121 L 921.6 88.681 L 896 103.461 L 870.4 88.681 L 870.4 59.121 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 947.2 44.341 L 972.8 59.121 L 972.8 88.681 L 947.2 103.461 L 921.6 88.681 L 921.6 59.121 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 998.4 44.341 L 1024 59.121 L 1024 88.681 L 998.4 103.461 L 972.8 88.681 L 972.8 59.121 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.91" d="M 1049.6 44.341 L 1075.2 59.121 L 1075.2 88.681 L 1049.6 103.461 L 1024 88.681 L 1024 59.121 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.82" d="M 0 88.681 L 25.6 103.461 L 25.6 133.022 L 0 147.802 L -25.6 133.022 L -25.6 103.461 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.47" d="M 51.2 88.681 L 76.8 103.461 L 76.8 133.022 L 51.2 147.802 L 25.6 133.022 L 25.6 103.461 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 102.4 88.681 L 128 103.461 L 128 133.022 L 102.4 147.802 L 76.8 133.022 L 76.8 103.461 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.47" d="M 153.6 88.681 L 179.2 103.461 L 179.2 133.022 L 153.6 147.802 L 128 133.022 L 128 103.461 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 204.8 88.681 L 230.4 103.461 L 230.4 133.022 L 204.8 147.802 L 179.2 133.022 L 179.2 103.461 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.78" d="M 256 88.681 L 281.6 103.461 L 281.6 133.022 L 256 147.802 L 230.4 133.022 L 230.4 103.461 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 307.2 88.681 L 332.8 103.461 L 332.8 133.022 L 307.2 147.802 L 281.6 133.022 L 281.6 103.461 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.86" d="M 358.4 88.681 L 384 103.461 L 384 133.022 L 358.4 147.802 L 332.8 133.022 L 332.8 103.461 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.69" d="M 409.6 88.681 L 435.2 103.461 L 435.2 133.022 L 409.6 147.802 L 384 133.022 L 384 103.461 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.94" d="M 460.8 88.681 L 486.4 103.461 L 486.4 133.022 L 460.8 147.802 L 435.2 133.022 L 435.2 103.461 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 512 88.681 L 537.6 103.461 L 537.6 133.022 L 512 147.802 L 486.4 133.022 L 486.4 103.461 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 563.2 88.681 L 588.8 103.461 L 588.8 133.022 L 563.2 147.802 L 537.6 133.022 L 537.6 103.461 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.31" d="M 614.4 88.681 L 640 103.461 L 640 133.022 L 614.4 147.802 L 588.8 133.022 L 588.8 103.461 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 665.6 88.681 L 691.2 103.461 L 691.2 133.022 L 665.6 147.802 L 640 133.022 L 640 103.461 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.65" d="M 716.8 88.681 L 742.4 103.461 L 742.4 133.022 L 716.8 147.802 L 691.2 133.022 L 691.2 103.461 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.22" d="M 768 88.681 L 793.6 103.461 L 793.6 133.022 L 768 147.802 L 742.4 133.022 L 742.4 103.461 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.5" d="M 819.2 88.681 L 844.8 103.461 L 844.8 133.022 L 819.2 147.802 L 793.6 133.022 L 793.6 103.461 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 870.4 88.681 L 896 103.461 L 896 133.022 L 870.4 147.802 L 844.8 133.022 L 844.8 103.461 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.71" d="M 921.6 88.681 L 947.2 103.461 L 947.2 133.022 L 921.6 147.802 L 896 133.022 L 896 103.461 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.66" d="M 972.8 88.681 L 998.4 103.461 L 998.4 133.022 L 972.8 147.802 L 947.2 133.022 L 947.2 103.461 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 1024 88.681 L 1049.6 103.461 L 1049.6 133.022 L 1024 147.802 L 998.4 133.022 L 998.4 103.461 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.59" d="M 25.6 133.022 L 51.2 147.802 L 51.2 177.362 L 25.6 192.142 L 0 177.362 L 0 147.802 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 76.8 133.022 L 102.4 147.802 L 102.4 177.362 L 76.8 192.142 L 51.2 177.362 L 51.2 147.802 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.68" d="M 128 133.022 L 153.6 147.802 L 153.6 177.362 L 128 192.142 L 102.4 177.362 L 102.4 147.802 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 179.2 133.022 L 204.8 147.802 L 204.8 177.362 L 179.2 192.142 L 153.6 177.362 L 153.6 147.802 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 230.4 133.022 L 256 147.802 L 256 177.362 L 230.4 192.142 L 204.8 177.362 L 204.8 147.802 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 281.6 133.022 L 307.2 147.802 L 307.2 177.362 L 281.6 192.142 L 256 177.362 L 256 147.802 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.34" d="M 332.8 133.022 L 358.4 147.802 L 358.4 177.362 L 332.8 192.142 L 307.2 177.362 L 307.2 147.802 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 384 133.022 L 409.6 147.802 L 409.6 177.362 L 384 192.142 L 358.4 177.362 L 358.4 147.802 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.31" d="M 435.2 133.022 L 460.8 147.802 L 460.8 177.362 L 435.2 192.142 L 409.6 177.362 L 409.6 147.802 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.36" d="M 486.4 133.022 L 512 147.802 L 512 177.362 L 486.4 192.142 L 460.8 177.362 L 460.8 147.802 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.72" d="M 537.6 133.022 L 563.2 147.802 L 563.2 177.362 L 537.6 192.142 L 512 177.362 L 512 147.802 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.27" d="M 588.8 133.022 L 614.4 147.802 L 614.4 177.362 L 588.8 192.142 L 563.2 177.362 L 563.2 147.802 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 640 133.022 L 665.6 147.802 L 665.6 177.362 L 640 192.142 L 614.4 177.362 L 614.4 147.802 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.95" d="M 691.2 133.022 L 716.8 147.802 L 716.8 177.362 L 691.2 192.142 L 665.6 177.362 L 665.6 147.802 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.58" d="M 742.4 133.022 L 768 147.802 L 768 177.362 L 742.4 192.142 L 716.8 177.362 L 716.8 147.802 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.54" d="M 793.6 133.022 L 819.2 147.802 L 819.2 177.362 L 793.6 192.142 L 768 177.362 L 768 147.802 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.56" d="M 844.8 133.022 L 870.4 147.802 L 870.4 177.362 L 844.8 192.142 L 819.2 177.362 L 819.2 147.802 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.44" d="M 896 133.022 L 921.6 147.802 L 921.6 177.362 L 896 192.142 L 870.4 177.362 L 870.4 147.802 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.53" d="M 947.2 133.022 L 972.8 147.802 L 972.8 177.362 L 947.2 192.142 L 921.6 177.362 L 921.6 147.802 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 998.4 133.022 L 1024 147.802 L 1024 177.362 L 998.4 192.142 L 972.8 177.362 L 972.8 147.802 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 1049.6 133.022 L 1075.2 147.802 L 1075.2 177.362 L 1049.6 192.142 L 1024 177.362 L 1024 147.802 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.26" d="M 0 177.362 L 25.6 192.142 L 25.6 221.703 L 0 236.483 L -25.6 221.703 L -25.6 192.142 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 51.2 177.362 L 76.8 192.142 L 76.8 221.703 L 51.2 236.483 L 25.6 221.703 L 25.6 192.142 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.94" d="M 102.4 177.362 L 128 192.142 L 128 221.703 L 102.4 236.483 L 76.8 221.703 L 76.8 192.142 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 153.6 177.362 L 179.2 192.142 L 179.2 221.703 L 153.6 236.483 L 128 221.703 L 128 192.142 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.39" d="M 204.8 177.362 L 230.4 192.142 L 230.4 221.703 L 204.8 236.483 L 179.2 221.703 L 179.2 192.142 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 256 177.362 L 281.6 192.142 L 281.6 221.703 L 256 236.483 L 230.4 221.703 L 230.4 192.142 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.69" d="M 307.2 177.362 L 332.8 192.142 L 332.8 221.703 L 307.2 236.483 L 281.6 221.703 L 281.6 192.142 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.42" d="M 358.4 177.362 L 384 192.142 L 384 221.703 L 358.4 236.483 L 332.8 221.703 L 332.8 192.142 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.51" d="M 409.6 177.362 L 435.2 192.142 L 435.2 221.703 L 409.6 236.483 L 384 221.703 L 384 192.142 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 460.8 177.362 L 486.4 192.142 L 486.4 221.703 L 460.8 236.483 L 435.2 221.703 L 435.2 192.142 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.46" d="M 512 177.362 L 537.6 192.142 L 537.6 221.703 L 512 236.483 L 486.4 221.703 L 486.4 192.142 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 563.2 177.362 L 588.8 192.142 L 588.8 221.703 L 563.2 236.483 L 537.6 221.703 L 537.6 192.142 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.45" d="M 614.4 177.362 L 640 192.142 L 640 221.703 L 614.4 236.483 L 588.8 221.703 L 588.8 192.142 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.35" d="M 665.6 177.362 L 691.2 192.142 L 691.2 221.703 L 665.6 236.483 L 640 221.703 L 640 192.142 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.77" d="M 716.8 177.362 L 742.4 192.142 L 742.4 221.703 L 716.8 236.483 L 691.2 221.703 L 691.2 192.142 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 768 177.362 L 793.6 192.142 L 793.6 221.703 L 768 236.483 L 742.4 221.703 L 742.4 192.142 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.44" d="M 819.2 177.362 L 844.8 192.142 L 844.8 221.703 L 819.2 236.483 L 793.6 221.703 L 793.6 192.142 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 870.4 177.362 L 896 192.142 L 896 221.703 L 870.4 236.483 L 844.8 221.703 L 844.8 192.142 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 921.6 177.362 L 947.2 192.142 L 947.2 221.703 L 921.6 236.483 L 896 221.703 L 896 192.142 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.44" d="M 972.8 177.362 L 998.4 192.142 L 998.4 221.703 L 972.8 236.483 L 947.2 221.703 L 947.2 192.142 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 1024 177.362 L 1049.6 192.142 L 1049.6 221.703 L 1024 236.483 L 998.4 221.703 L 998.4 192.142 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 25.6 221.703 L 51.2 236.483 L 51.2 266.043 L 25.6 280.823 L 0 266.043 L 0 236.483 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.47" d="M 76.8 221.703 L 102.4 236.483 L 102.4 266.043 L 76.8 280.823 L 51.2 266.043 L 51.2 236.483 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.97" d="M 128 221.703 L 153.6 236.483 L 153.6 266.043 L 128 280.823 L 102.4 266.043 L 102.4 236.483 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 179.2 221.703 L 204.8 236.483 L 204.8 266.043 L 179.2 280.823 L 153.6 266.043 L 153.6 236.483 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.58" d="M 230.4 221.703 L 256 236.483 L 256 266.043 L 230.4 280.823 L 204.8 266.043 L 204.8 236.483 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.77" d="M 281.6 221.703 L 307.2 236.483 L 307.2 266.043 L 281.6 280.823 L 256 266.043 L 256 236.483 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.24" d="M 332.8 221.703 L 358.4 236.483 L 358.4 266.043 L 332.8 280.823 L 307.2 266.043 L 307.2 236.483 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.85" d="M 384 221.703 L 409.6 236.483 L 409.6 266.043 L 384 280.823 L 358.4 266.043 L 358.4 236.483 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.23" d="M 435.2 221.703 L 460.8 236.483 L 460.8 266.043 L 435.2 280.823 L 409.6 266.043 L 409.6 236.483 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.22" d="M 486.4 221.703 L 512 236.483 L 512 266.043 L 486.4 280.823 L 460.8 266.043 L 460.8 236.483 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 537.6 221.703 L 563.2 236.483 L 563.2 266.043 L 537.6 280.823 L 512 266.043 L 512 236.483 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.32" d="M 588.8 221.703 L 614.4 236.483 L 614.4 266.043 L 588.8 280.823 L 563.2 266.043 L 563.2 236.483 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.24" d="M 640 221.703 L 665.6 236.483 L 665.6 266.043 L 640 280.823 L 614.4 266.043 L 614.4 236.483 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 691.2 221.703 L 716.8 236.483 L 716.8 266.043 L 691.2 280.823 L 665.6 266.043 L 665.6 236.483 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 742.4 221.703 L 768 236.483 L 768 266.043 L 742.4 280.823 L 716.8 266.043 L 716.8 236.483 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.42" d="M 793.6 221.703 L 819.2 236.483 L 819.2 266.043 L 793.6 280.823 L 768 266.043 L 768 236.483 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 844.8 221.703 L 870.4 236.483 L 870.4 266.043 L 844.8 280.823 L 819.2 266.043 L 819.2 236.483 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 896 221.703 L 921.6 236.483 L 921.6 266.043 L 896 280.823 L 870.4 266.043 L 870.4 236.483 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 947.2 221.703 L 972.8 236.483 L 972.8 266.043 L 947.2 280.823 L 921.6 266.043 L 921.6 236.483 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.81" d="M 998.4 221.703 L 1024 236.483 L 1024 266.043 L 998.4 280.823 L 972.8 266.043 L 972.8 236.483 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.32" d="M 1049.6 221.703 L 1075.2 236.483 L 1075.2 266.043 L 1049.6 280.823 L 1024 266.043 L 1024 236.483 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.22" d="M 0 266.043 L 25.6 280.823 L 25.6 310.384 L 0 325.164 L -25.6 310.384 L -25.6 280.823 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.73" d="M 51.2 266.043 L 76.8 280.823 L 76.8 310.384 L 51.2 325.164 L 25.6 310.384 L 25.6 280.823 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.28" d="M 102.4 266.043 L 128 280.823 L 128 310.384 L 102.4 325.164 L 76.8 310.384 L 76.8 280.823 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 153.6 266.043 L 179.2 280.823 L 179.2 310.384 L 153.6 325.164 L 128 310.384 L 128 280.823 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.49" d="M 204.8 266.043 L 230.4 280.823 L 230.4 310.384 L 204.8 325.164 L 179.2 310.384 L 179.2 280.823 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.39" d="M 256 266.043 L 281.6 280.823 L 281.6 310.384 L 256 325.164 L 230.4 310.384 L 230.4 280.823 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 307.2 266.043 L 332.8 280.823 L 332.8 310.384 L 307.2 325.164 L 281.6 310.384 L 281.6 280.823 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.72" d="M 358.4 266.043 L 384 280.823 L 384 310.384 L 358.4 325.164 L 332.8 310.384 L 332.8 280.823 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.38" d="M 409.6 266.043 L 435.2 280.823 L 435.2 310.384 L 409.6 325.164 L 384 310.384 L 384 280.823 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.64" d="M 460.8 266.043 L 486.4 280.823 L 486.4 310.384 L 460.8 325.164 L 435.2 310.384 L 435.2 280.823 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.68" d="M 512 266.043 L 537.6 280.823 L 537.6 310.384 L 512 325.164 L 486.4 310.384 L 486.4 280.823 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 563.2 266.043 L 588.8 280.823 L 588.8 310.384 L 563.2 325.164 L 537.6 310.384 L 537.6 280.823 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 614.4 266.043 L 640 280.823 L 640 310.384 L 614.4 325.164 L 588.8 310.384 L 588.8 280.823 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 665.6 266.043 L 691.2 280.823 L 691.2 310.384 L 665.6 325.164 L 640 310.384 L 640 280.823 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.23" d="M 716.8 266.043 L 742.4 280.823 L 742.4 310.384 L 716.8 325.164 L 691.2 310.384 L 691.2 280.823 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 768 266.043 L 793.6 280.823 L 793.6 310.384 L 768 325.164 L 742.4 310.384 L 742.4 280.823 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 819.2 266.043 L 844.8 280.823 L 844.8 310.384 L 819.2 325.164 L 793.6 310.384 L 793.6 280.823 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 870.4 266.043 L 896 280.823 L 896 310.384 L 870.4 325.164 L 844.8 310.384 L 844.8 280.823 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 921.6 266.043 L 947.2 280.823 L 947.2 310.384 L 921.6 325.164 L 896 310.384 L 896 280.823 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.31" d="M 972.8 266.043 L 998.4 280.823 L 998.4 310.384 L 972.8 325.164 L 947.2 310.384 L 947.2 280.823 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.33" d="M 1024 266.043 L 1049.6 280.823 L 1049.6 310.384 L 1024 325.164 L 998.4 310.384 L 998.4 280.823 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.44" d="M 25.6 310.384 L 51.2 325.164 L 51.2 354.724 L 25.6 369.504 L 0 354.724 L 0 325.164 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 76.8 310.384 L 102.4 325.164 L 102.4 354.724 L 76.8 369.504 L 51.2 354.724 L 51.2 325.164 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.32" d="M 128 310.384 L 153.6 325.164 L 153.6 354.724 L 128 369.504 L 102.4 354.724 L 102.4 325.164 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 179.2 310.384 L 204.8 325.164 L 204.8 354.724 L 179.2 369.504 L 153.6 354.724 L 153.6 325.164 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 230.4 310.384 L 256 325.164 L 256 354.724 L 230.4 369.504 L 204.8 354.724 L 204.8 325.164 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.46" d="M 281.6 310.384 L 307.2 325.164 L 307.2 354.724 L 281.6 369.504 L 256 354.724 L 256 325.164 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 332.8 310.384 L 358.4 325.164 L 358.4 354.724 L 332.8 369.504 L 307.2 354.724 L 307.2 325.164 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 384 310.384 L 409.6 325.164 L 409.6 354.724 L 384 369.504 L 358.4 354.724 L 358.4 325.164 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.62" d="M 435.2 310.384 L 460.8 325.164 L 460.8 354.724 L 435.2 369.504 L 409.6 354.724 L 409.6 325.164 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.59" d="M 486.4 310.384 L 512 325.164 L 512 354.724 L 486.4 369.504 L 460.8 354.724 L 460.8 325.164 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 537.6 310.384 L 563.2 325.164 L 563.2 354.724 L 537.6 369.504 L 512 354.724 L 512 325.164 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 588.8 310.384 L 614.4 325.164 L 614.4 354.724 L 588.8 369.504 L 563.2 354.724 L 563.2 325.164 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.81" d="M 640 310.384 L 665.6 325.164 L 665.6 354.724 L 640 369.504 L 614.4 354.724 L 614.4 325.164 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.52" d="M 691.2 310.384 L 716.8 325.164 L 716.8 354.724 L 691.2 369.504 L 665.6 354.724 L 665.6 325.164 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.73" d="M 742.4 310.384 L 768 325.164 L 768 354.724 L 742.4 369.504 L 716.8 354.724 L 716.8 325.164 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.32" d="M 793.6 310.384 L 819.2 325.164 L 819.2 354.724 L 793.6 369.504 L 768 354.724 L 768 325.164 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.34" d="M 844.8 310.384 L 870.4 325.164 L 870.4 354.724 L 844.8 369.504 L 819.2 354.724 L 819.2 325.164 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.61" d="M 896 310.384 L 921.6 325.164 L 921.6 354.724 L 896 369.504 L 870.4 354.724 L 870.4 325.164 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 947.2 310.384 L 972.8 325.164 L 972.8 354.724 L 947.2 369.504 L 921.6 354.724 L 921.6 325.164 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 998.4 310.384 L 1024 325.164 L 1024 354.724 L 998.4 369.504 L 972.8 354.724 L 972.8 325.164 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 1049.6 310.384 L 1075.2 325.164 L 1075.2 354.724 L 1049.6 369.504 L 1024 354.724 L 1024 325.164 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.84" d="M 0 354.724 L 25.6 369.504 L 25.6 399.065 L 0 413.845 L -25.6 399.065 L -25.6 369.504 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.37" d="M 51.2 354.724 L 76.8 369.504 L 76.8 399.065 L 51.2 413.845 L 25.6 399.065 L 25.6 369.504 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 102.4 354.724 L 128 369.504 L 128 399.065 L 102.4 413.845 L 76.8 399.065 L 76.8 369.504 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 153.6 354.724 L 179.2 369.504 L 179.2 399.065 L 153.6 413.845 L 128 399.065 L 128 369.504 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 204.8 354.724 L 230.4 369.504 L 230.4 399.065 L 204.8 413.845 L 179.2 399.065 L 179.2 369.504 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.54" d="M 256 354.724 L 281.6 369.504 L 281.6 399.065 L 256 413.845 L 230.4 399.065 L 230.4 369.504 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 307.2 354.724 L 332.8 369.504 L 332.8 399.065 L 307.2 413.845 L 281.6 399.065 L 281.6 369.504 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 358.4 354.724 L 384 369.504 L 384 399.065 L 358.4 413.845 L 332.8 399.065 L 332.8 369.504 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.43" d="M 409.6 354.724 L 435.2 369.504 L 435.2 399.065 L 409.6 413.845 L 384 399.065 L 384 369.504 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 460.8 354.724 L 486.4 369.504 L 486.4 399.065 L 460.8 413.845 L 435.2 399.065 L 435.2 369.504 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.5" d="M 512 354.724 L 537.6 369.504 L 537.6 399.065 L 512 413.845 L 486.4 399.065 L 486.4 369.504 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.22" d="M 563.2 354.724 L 588.8 369.504 L 588.8 399.065 L 563.2 413.845 L 537.6 399.065 L 537.6 369.504 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.24" d="M 614.4 354.724 L 640 369.504 L 640 399.065 L 614.4 413.845 L 588.8 399.065 L 588.8 369.504 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.24" d="M 665.6 354.724 L 691.2 369.504 L 691.2 399.065 L 665.6 413.845 L 640 399.065 L 640 369.504 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.67" d="M 716.8 354.724 L 742.4 369.504 L 742.4 399.065 L 716.8 413.845 L 691.2 399.065 L 691.2 369.504 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 768 354.724 L 793.6 369.504 L 793.6 399.065 L 768 413.845 L 742.4 399.065 L 742.4 369.504 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.82" d="M 819.2 354.724 L 844.8 369.504 L 844.8 399.065 L 819.2 413.845 L 793.6 399.065 L 793.6 369.504 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.86" d="M 870.4 354.724 L 896 369.504 L 896 399.065 L 870.4 413.845 L 844.8 399.065 L 844.8 369.504 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.22" d="M 921.6 354.724 L 947.2 369.504 L 947.2 399.065 L 921.6 413.845 L 896 399.065 L 896 369.504 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.75" d="M 972.8 354.724 L 998.4 369.504 L 998.4 399.065 L 972.8 413.845 L 947.2 399.065 L 947.2 369.504 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 1024 354.724 L 1049.6 369.504 L 1049.6 399.065 L 1024 413.845 L 998.4 399.065 L 998.4 369.504 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 25.6 399.065 L 51.2 413.845 L 51.2 443.405 L 25.6 458.185 L 0 443.405 L 0 413.845 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.54" d="M 76.8 399.065 L 102.4 413.845 L 102.4 443.405 L 76.8 458.185 L 51.2 443.405 L 51.2 413.845 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 128 399.065 L 153.6 413.845 L 153.6 443.405 L 128 458.185 L 102.4 443.405 L 102.4 413.845 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.33" d="M 179.2 399.065 L 204.8 413.845 L 204.8 443.405 L 179.2 458.185 L 153.6 443.405 L 153.6 413.845 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.55" d="M 230.4 399.065 L 256 413.845 L 256 443.405 L 230.4 458.185 L 204.8 443.405 L 204.8 413.845 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 281.6 399.065 L 307.2 413.845 L 307.2 443.405 L 281.6 458.185 L 256 443.405 L 256 413.845 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.99" d="M 332.8 399.065 L 358.4 413.845 L 358.4 443.405 L 332.8 458.185 L 307.2 443.405 L 307.2 413.845 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.44" d="M 384 399.065 L 409.6 413.845 L 409.6 443.405 L 384 458.185 L 358.4 443.405 L 358.4 413.845 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 435.2 399.065 L 460.8 413.845 L 460.8 443.405 L 435.2 458.185 L 409.6 443.405 L 409.6 413.845 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.64" d="M 486.4 399.065 L 512 413.845 L 512 443.405 L 486.4 458.185 L 460.8 443.405 L 460.8 413.845 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 537.6 399.065 L 563.2 413.845 L 563.2 443.405 L 537.6 458.185 L 512 443.405 L 512 413.845 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 588.8 399.065 L 614.4 413.845 L 614.4 443.405 L 588.8 458.185 L 563.2 443.405 L 563.2 413.845 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 640 399.065 L 665.6 413.845 L 665.6 443.405 L 640 458.185 L 614.4 443.405 L 614.4 413.845 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.63" d="M 691.2 399.065 L 716.8 413.845 L 716.8 443.405 L 691.2 458.185 L 665.6 443.405 L 665.6 413.845 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.46" d="M 742.4 399.065 L 768 413.845 L 768 443.405 L 742.4 458.185 L 716.8 443.405 L 716.8 413.845 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.82" d="M 793.6 399.065 L 819.2 413.845 L 819.2 443.405 L 793.6 458.185 L 768 443.405 L 768 413.845 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.8" d="M 844.8 399.065 L 870.4 413.845 L 870.4 443.405 L 844.8 458.185 L 819.2 443.405 L 819.2 413.845 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 896 399.065 L 921.6 413.845 L 921.6 443.405 L 896 458.185 L 870.4 443.405 L 870.4 413.845 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.47" d="M 947.2 399.065 L 972.8 413.845 L 972.8 443.405 L 947.2 458.185 L 921.6 443.405 L 921.6 413.845 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 998.4 399.065 L 1024 413.845 L 1024 443.405 L 998.4 458.185 L 972.8 443.405 L 972.8 413.845 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.41" d="M 1049.6 399.065 L 1075.2 413.845 L 1075.2 443.405 L 1049.6 458.185 L 1024 443.405 L 1024 413.845 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.3" d="M 0 443.405 L 25.6 458.185 L 25.6 487.746 L 0 502.526 L -25.6 487.746 L -25.6 458.185 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.61" d="M 51.2 443.405 L 76.8 458.185 L 76.8 487.746 L 51.2 502.526 L 25.6 487.746 L 25.6 458.185 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 102.4 443.405 L 128 458.185 L 128 487.746 L 102.4 502.526 L 76.8 487.746 L 76.8 458.185 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.21" d="M 153.6 443.405 L 179.2 458.185 L 179.2 487.746 L 153.6 502.526 L 128 487.746 L 128 458.185 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.72" d="M 204.8 443.405 L 230.4 458.185 L 230.4 487.746 L 204.8 502.526 L 179.2 487.746 L 179.2 458.185 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.31" d="M 256 443.405 L 281.6 458.185 L 281.6 487.746 L 256 502.526 L 230.4 487.746 L 230.4 458.185 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 307.2 443.405 L 332.8 458.185 L 332.8 487.746 L 307.2 502.526 L 281.6 487.746 L 281.6 458.185 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 358.4 443.405 L 384 458.185 L 384 487.746 L 358.4 502.526 L 332.8 487.746 L 332.8 458.185 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.7" d="M 409.6 443.405 L 435.2 458.185 L 435.2 487.746 L 409.6 502.526 L 384 487.746 L 384 458.185 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.3" d="M 460.8 443.405 L 486.4 458.185 L 486.4 487.746 L 460.8 502.526 L 435.2 487.746 L 435.2 458.185 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.21" d="M 512 443.405 L 537.6 458.185 L 537.6 487.746 L 512 502.526 L 486.4 487.746 L 486.4 458.185 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 563.2 443.405 L 588.8 458.185 L 588.8 487.746 L 563.2 502.526 L 537.6 487.746 L 537.6 458.185 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 614.4 443.405 L 640 458.185 L 640 487.746 L 614.4 502.526 L 588.8 487.746 L 588.8 458.185 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.48" d="M 665.6 443.405 L 691.2 458.185 L 691.2 487.746 L 665.6 502.526 L 640 487.746 L 640 458.185 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 716.8 443.405 L 742.4 458.185 L 742.4 487.746 L 716.8 502.526 L 691.2 487.746 L 691.2 458.185 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 768 443.405 L 793.6 458.185 L 793.6 487.746 L 768 502.526 L 742.4 487.746 L 742.4 458.185 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.64" d="M 819.2 443.405 L 844.8 458.185 L 844.8 487.746 L 819.2 502.526 L 793.6 487.746 L 793.6 458.185 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.76" d="M 870.4 443.405 L 896 458.185 L 896 487.746 L 870.4 502.526 L 844.8 487.746 L 844.8 458.185 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 921.6 443.405 L 947.2 458.185 L 947.2 487.746 L 921.6 502.526 L 896 487.746 L 896 458.185 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.87" d="M 972.8 443.405 L 998.4 458.185 L 998.4 487.746 L 972.8 502.526 L 947.2 487.746 L 947.2 458.185 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 1024 443.405 L 1049.6 458.185 L 1049.6 487.746 L 1024 502.526 L 998.4 487.746 L 998.4 458.185 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.37" d="M 25.6 487.746 L 51.2 502.526 L 51.2 532.086 L 25.6 546.866 L 0 532.086 L 0 502.526 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.35" d="M 76.8 487.746 L 102.4 502.526 L 102.4 532.086 L 76.8 546.866 L 51.2 532.086 L 51.2 502.526 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.3" d="M 128 487.746 L 153.6 502.526 L 153.6 532.086 L 128 546.866 L 102.4 532.086 L 102.4 502.526 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.33" d="M 179.2 487.746 L 204.8 502.526 L 204.8 532.086 L 179.2 546.866 L 153.6 532.086 L 153.6 502.526 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.29" d="M 230.4 487.746 L 256 502.526 L 256 532.086 L 230.4 546.866 L 204.8 532.086 L 204.8 502.526 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.46" d="M 281.6 487.746 L 307.2 502.526 L 307.2 532.086 L 281.6 546.866 L 256 532.086 L 256 502.526 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 332.8 487.746 L 358.4 502.526 L 358.4 532.086 L 332.8 546.866 L 307.2 532.086 L 307.2 502.526 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.3" d="M 384 487.746 L 409.6 502.526 L 409.6 532.086 L 384 546.866 L 358.4 532.086 L 358.4 502.526 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 435.2 487.746 L 460.8 502.526 L 460.8 532.086 L 435.2 546.866 L 409.6 532.086 L 409.6 502.526 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 486.4 487.746 L 512 502.526 L 512 532.086 L 486.4 546.866 L 460.8 532.086 L 460.8 502.526 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.36" d="M 537.6 487.746 L 563.2 502.526 L 563.2 532.086 L 537.6 546.866 L 512 532.086 L 512 502.526 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 588.8 487.746 L 614.4 502.526 L 614.4 532.086 L 588.8 546.866 L 563.2 532.086 L 563.2 502.526 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.26" d="M 640 487.746 L 665.6 502.526 L 665.6 532.086 L 640 546.866 L 614.4 532.086 L 614.4 502.526 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.56" d="M 691.2 487.746 L 716.8 502.526 L 716.8 532.086 L 691.2 546.866 L 665.6 532.086 L 665.6 502.526 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.86" d="M 742.4 487.746 L 768 502.526 L 768 532.086 L 742.4 546.866 L 716.8 532.086 L 716.8 502.526 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.21" d="M 793.6 487.746 L 819.2 502.526 L 819.2 532.086 L 793.6 546.866 L 768 532.086 L 768 502.526 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 844.8 487.746 L 870.4 502.526 L 870.4 532.086 L 844.8 546.866 L 819.2 532.086 L 819.2 502.526 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 896 487.746 L 921.6 502.526 L 921.6 532.086 L 896 546.866 L 870.4 532.086 L 870.4 502.526 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 947.2 487.746 L 972.8 502.526 L 972.8 532.086 L 947.2 546.866 L 921.6 532.086 L 921.6 502.526 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.96" d="M 998.4 487.746 L 1024 502.526 L 1024 532.086 L 998.4 546.866 L 972.8 532.086 L 972.8 502.526 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.91" d="M 1049.6 487.746 L 1075.2 502.526 L 1075.2 532.086 L 1049.6 546.866 L 1024 532.086 L 1024 502.526 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.84" d="M 0 532.086 L 25.6 546.866 L 25.6 576.427 L 0 591.207 L -25.6 576.427 L -25.6 546.866 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.86" d="M 51.2 532.086 L 76.8 546.866 L 76.8 576.427 L 51.2 591.207 L 25.6 576.427 L 25.6 546.866 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.75" d="M 102.4 532.086 L 128 546.866 L 128 576.427 L 102.4 591.207 L 76.8 576.427 L 76.8 546.866 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 153.6 532.086 L 179.2 546.866 L 179.2 576.427 L 153.6 591.207 L 128 576.427 L 128 546.866 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.37" d="M 204.8 532.086 L 230.4 546.866 L 230.4 576.427 L 204.8 591.207 L 179.2 576.427 L 179.2 546.866 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.81" d="M 256 532.086 L 281.6 546.866 L 281.6 576.427 L 256 591.207 L 230.4 576.427 L 230.4 546.866 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 307.2 532.086 L 332.8 546.866 L 332.8 576.427 L 307.2 591.207 L 281.6 576.427 L 281.6 546.866 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.81" d="M 358.4 532.086 L 384 546.866 L 384 576.427 L 358.4 591.207 L 332.8 576.427 L 332.8 546.866 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.38" d="M 409.6 532.086 L 435.2 546.866 L 435.2 576.427 L 409.6 591.207 L 384 576.427 L 384 546.866 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.72" d="M 460.8 532.086 L 486.4 546.866 L 486.4 576.427 L 460.8 591.207 L 435.2 576.427 L 435.2 546.866 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.52" d="M 512 532.086 L 537.6 546.866 L 537.6 576.427 L 512 591.207 L 486.4 576.427 L 486.4 546.866 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 563.2 532.086 L 588.8 546.866 L 588.8 576.427 L 563.2 591.207 L 537.6 576.427 L 537.6 546.866 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.29" d="M 614.4 532.086 L 640 546.866 L 640 576.427 L 614.4 591.207 L 588.8 576.427 L 588.8 546.866 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.65" d="M 665.6 532.086 L 691.2 546.866 L 691.2 576.427 L 665.6 591.207 L 640 576.427 L 640 546.866 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 716.8 532.086 L 742.4 546.866 L 742.4 576.427 L 716.8 591.207 L 691.2 576.427 L 691.2 546.866 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.78" d="M 768 532.086 L 793.6 546.866 L 793.6 576.427 L 768 591.207 L 742.4 576.427 L 742.4 546.866 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.3" d="M 819.2 532.086 L 844.8 546.866 L 844.8 576.427 L 819.2 591.207 L 793.6 576.427 L 793.6 546.866 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.29" d="M 870.4 532.086 L 896 546.866 L 896 576.427 L 870.4 591.207 L 844.8 576.427 L 844.8 546.866 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.66" d="M 921.6 532.086 L 947.2 546.866 L 947.2 576.427 L 921.6 591.207 L 896 576.427 L 896 546.866 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.64" d="M 972.8 532.086 L 998.4 546.866 L 998.4 576.427 L 972.8 591.207 L 947.2 576.427 L 947.2 546.866 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.51" d="M 1024 532.086 L 1049.6 546.866 L 1049.6 576.427 L 1024 591.207 L 998.4 576.427 L 998.4 546.866 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 25.6 576.427 L 51.2 591.207 L 51.2 620.767 L 25.6 635.547 L 0 620.767 L 0 591.207 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 76.8 576.427 L 102.4 591.207 L 102.4 620.767 L 76.8 635.547 L 51.2 620.767 L 51.2 591.207 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 128 576.427 L 153.6 591.207 L 153.6 620.767 L 128 635.547 L 102.4 620.767 L 102.4 591.207 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.76" d="M 179.2 576.427 L 204.8 591.207 L 204.8 620.767 L 179.2 635.547 L 153.6 620.767 L 153.6 591.207 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.48" d="M 230.4 576.427 L 256 591.207 L 256 620.767 L 230.4 635.547 L 204.8 620.767 L 204.8 591.207 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 281.6 576.427 L 307.2 591.207 L 307.2 620.767 L 281.6 635.547 L 256 620.767 L 256 591.207 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.21" d="M 332.8 576.427 L 358.4 591.207 L 358.4 620.767 L 332.8 635.547 L 307.2 620.767 L 307.2 591.207 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.36" d="M 384 576.427 L 409.6 591.207 L 409.6 620.767 L 384 635.547 L 358.4 620.767 L 358.4 591.207 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 435.2 576.427 L 460.8 591.207 L 460.8 620.767 L 435.2 635.547 L 409.6 620.767 L 409.6 591.207 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.47" d="M 486.4 576.427 L 512 591.207 L 512 620.767 L 486.4 635.547 L 460.8 620.767 L 460.8 591.207 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 537.6 576.427 L 563.2 591.207 L 563.2 620.767 L 537.6 635.547 L 512 620.767 L 512 591.207 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.53" d="M 588.8 576.427 L 614.4 591.207 L 614.4 620.767 L 588.8 635.547 L 563.2 620.767 L 563.2 591.207 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 640 576.427 L 665.6 591.207 L 665.6 620.767 L 640 635.547 L 614.4 620.767 L 614.4 591.207 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.35" d="M 691.2 576.427 L 716.8 591.207 L 716.8 620.767 L 691.2 635.547 L 665.6 620.767 L 665.6 591.207 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.9" d="M 742.4 576.427 L 768 591.207 L 768 620.767 L 742.4 635.547 L 716.8 620.767 L 716.8 591.207 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 793.6 576.427 L 819.2 591.207 L 819.2 620.767 L 793.6 635.547 L 768 620.767 L 768 591.207 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 844.8 576.427 L 870.4 591.207 L 870.4 620.767 L 844.8 635.547 L 819.2 620.767 L 819.2 591.207 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 896 576.427 L 921.6 591.207 L 921.6 620.767 L 896 635.547 L 870.4 620.767 L 870.4 591.207 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.78" d="M 947.2 576.427 L 972.8 591.207 L 972.8 620.767 L 947.2 635.547 L 921.6 620.767 L 921.6 591.207 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.78" d="M 998.4 576.427 L 1024 591.207 L 1024 620.767 L 998.4 635.547 L 972.8 620.767 L 972.8 591.207 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 1049.6 576.427 L 1075.2 591.207 L 1075.2 620.767 L 1049.6 635.547 L 1024 620.767 L 1024 591.207 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 0 620.767 L 25.6 635.547 L 25.6 665.108 L 0 679.888 L -25.6 665.108 L -25.6 635.547 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.57" d="M 51.2 620.767 L 76.8 635.547 L 76.8 665.108 L 51.2 679.888 L 25.6 665.108 L 25.6 635.547 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.37" d="M 102.4 620.767 L 128 635.547 L 128 665.108 L 102.4 679.888 L 76.8 665.108 L 76.8 635.547 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.94" d="M 153.6 620.767 L 179.2 635.547 L 179.2 665.108 L 153.6 679.888 L 128 665.108 L 128 635.547 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.67" d="M 204.8 620.767 L 230.4 635.547 L 230.4 665.108 L 204.8 679.888 L 179.2 665.108 L 179.2 635.547 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.54" d="M 256 620.767 L 281.6 635.547 L 281.6 665.108 L 256 679.888 L 230.4 665.108 L 230.4 635.547 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.7" d="M 307.2 620.767 L 332.8 635.547 L 332.8 665.108 L 307.2 679.888 L 281.6 665.108 L 281.6 635.547 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.46" d="M 358.4 620.767 L 384 635.547 L 384 665.108 L 358.4 679.888 L 332.8 665.108 L 332.8 635.547 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 409.6 620.767 L 435.2 635.547 L 435.2 665.108 L 409.6 679.888 L 384 665.108 L 384 635.547 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 460.8 620.767 L 486.4 635.547 L 486.4 665.108 L 460.8 679.888 L 435.2 665.108 L 435.2 635.547 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.68" d="M 512 620.767 L 537.6 635.547 L 537.6 665.108 L 512 679.888 L 486.4 665.108 L 486.4 635.547 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 563.2 620.767 L 588.8 635.547 L 588.8 665.108 L 563.2 679.888 L 537.6 665.108 L 537.6 635.547 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 614.4 620.767 L 640 635.547 L 640 665.108 L 614.4 679.888 L 588.8 665.108 L 588.8 635.547 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.25" d="M 665.6 620.767 L 691.2 635.547 L 691.2 665.108 L 665.6 679.888 L 640 665.108 L 640 635.547 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.91" d="M 716.8 620.767 L 742.4 635.547 L 742.4 665.108 L 716.8 679.888 L 691.2 665.108 L 691.2 635.547 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 768 620.767 L 793.6 635.547 L 793.6 665.108 L 768 679.888 L 742.4 665.108 L 742.4 635.547 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.42" d="M 819.2 620.767 L 844.8 635.547 L 844.8 665.108 L 819.2 679.888 L 793.6 665.108 L 793.6 635.547 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.84" d="M 870.4 620.767 L 896 635.547 L 896 665.108 L 870.4 679.888 L 844.8 665.108 L 844.8 635.547 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 921.6 620.767 L 947.2 635.547 L 947.2 665.108 L 921.6 679.888 L 896 665.108 L 896 635.547 Z"/> +<path stroke="#222" stroke-width="1" fill="none" d="M 972.8 620.767 L 998.4 635.547 L 998.4 665.108 L 972.8 679.888 L 947.2 665.108 L 947.2 635.547 Z"/> +<path stroke="#222" stroke-width="1" fill="#222" fill-opacity="0.36" d="M 1024 620.767 L 1049.6 635.547 L 1049.6 665.108 L 1024 679.888 L 998.4 665.108 L 998.4 635.547 Z"/> +</svg> diff --git a/server/web/src/assets/hex-grid-light.svg b/server/web/src/assets/hex-grid-light.svg @@ -0,0 +1,339 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="576" viewBox="0 0 1024 576"> +<rect width="100%" height="100%" fill="#FFF"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 25.6 -44.341 L 51.2 -29.56 L 51.2 0 L 25.6 14.78 L 0 0 L 0 -29.56 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 76.8 -44.341 L 102.4 -29.56 L 102.4 0 L 76.8 14.78 L 51.2 0 L 51.2 -29.56 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.76" d="M 128 -44.341 L 153.6 -29.56 L 153.6 0 L 128 14.78 L 102.4 0 L 102.4 -29.56 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 179.2 -44.341 L 204.8 -29.56 L 204.8 0 L 179.2 14.78 L 153.6 0 L 153.6 -29.56 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.46" d="M 230.4 -44.341 L 256 -29.56 L 256 0 L 230.4 14.78 L 204.8 0 L 204.8 -29.56 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.31" d="M 281.6 -44.341 L 307.2 -29.56 L 307.2 0 L 281.6 14.78 L 256 0 L 256 -29.56 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 332.8 -44.341 L 358.4 -29.56 L 358.4 0 L 332.8 14.78 L 307.2 0 L 307.2 -29.56 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.39" d="M 384 -44.341 L 409.6 -29.56 L 409.6 0 L 384 14.78 L 358.4 0 L 358.4 -29.56 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.32" d="M 435.2 -44.341 L 460.8 -29.56 L 460.8 0 L 435.2 14.78 L 409.6 0 L 409.6 -29.56 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 486.4 -44.341 L 512 -29.56 L 512 0 L 486.4 14.78 L 460.8 0 L 460.8 -29.56 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 537.6 -44.341 L 563.2 -29.56 L 563.2 0 L 537.6 14.78 L 512 0 L 512 -29.56 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 588.8 -44.341 L 614.4 -29.56 L 614.4 0 L 588.8 14.78 L 563.2 0 L 563.2 -29.56 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 640 -44.341 L 665.6 -29.56 L 665.6 0 L 640 14.78 L 614.4 0 L 614.4 -29.56 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.82" d="M 691.2 -44.341 L 716.8 -29.56 L 716.8 0 L 691.2 14.78 L 665.6 0 L 665.6 -29.56 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 742.4 -44.341 L 768 -29.56 L 768 0 L 742.4 14.78 L 716.8 0 L 716.8 -29.56 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.87" d="M 793.6 -44.341 L 819.2 -29.56 L 819.2 0 L 793.6 14.78 L 768 0 L 768 -29.56 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 844.8 -44.341 L 870.4 -29.56 L 870.4 0 L 844.8 14.78 L 819.2 0 L 819.2 -29.56 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 896 -44.341 L 921.6 -29.56 L 921.6 0 L 896 14.78 L 870.4 0 L 870.4 -29.56 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.42" d="M 947.2 -44.341 L 972.8 -29.56 L 972.8 0 L 947.2 14.78 L 921.6 0 L 921.6 -29.56 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 998.4 -44.341 L 1024 -29.56 L 1024 0 L 998.4 14.78 L 972.8 0 L 972.8 -29.56 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.89" d="M 1049.6 -44.341 L 1075.2 -29.56 L 1075.2 0 L 1049.6 14.78 L 1024 0 L 1024 -29.56 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 0 0 L 25.6 14.78 L 25.6 44.341 L 0 59.121 L -25.6 44.341 L -25.6 14.78 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.29" d="M 51.2 0 L 76.8 14.78 L 76.8 44.341 L 51.2 59.121 L 25.6 44.341 L 25.6 14.78 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 102.4 0 L 128 14.78 L 128 44.341 L 102.4 59.121 L 76.8 44.341 L 76.8 14.78 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.61" d="M 153.6 0 L 179.2 14.78 L 179.2 44.341 L 153.6 59.121 L 128 44.341 L 128 14.78 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 204.8 0 L 230.4 14.78 L 230.4 44.341 L 204.8 59.121 L 179.2 44.341 L 179.2 14.78 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.33" d="M 256 0 L 281.6 14.78 L 281.6 44.341 L 256 59.121 L 230.4 44.341 L 230.4 14.78 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 307.2 0 L 332.8 14.78 L 332.8 44.341 L 307.2 59.121 L 281.6 44.341 L 281.6 14.78 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 358.4 0 L 384 14.78 L 384 44.341 L 358.4 59.121 L 332.8 44.341 L 332.8 14.78 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.38" d="M 409.6 0 L 435.2 14.78 L 435.2 44.341 L 409.6 59.121 L 384 44.341 L 384 14.78 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 460.8 0 L 486.4 14.78 L 486.4 44.341 L 460.8 59.121 L 435.2 44.341 L 435.2 14.78 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 512 0 L 537.6 14.78 L 537.6 44.341 L 512 59.121 L 486.4 44.341 L 486.4 14.78 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.97" d="M 563.2 0 L 588.8 14.78 L 588.8 44.341 L 563.2 59.121 L 537.6 44.341 L 537.6 14.78 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.51" d="M 614.4 0 L 640 14.78 L 640 44.341 L 614.4 59.121 L 588.8 44.341 L 588.8 14.78 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 665.6 0 L 691.2 14.78 L 691.2 44.341 L 665.6 59.121 L 640 44.341 L 640 14.78 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 716.8 0 L 742.4 14.78 L 742.4 44.341 L 716.8 59.121 L 691.2 44.341 L 691.2 14.78 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.44" d="M 768 0 L 793.6 14.78 L 793.6 44.341 L 768 59.121 L 742.4 44.341 L 742.4 14.78 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 819.2 0 L 844.8 14.78 L 844.8 44.341 L 819.2 59.121 L 793.6 44.341 L 793.6 14.78 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.49" d="M 870.4 0 L 896 14.78 L 896 44.341 L 870.4 59.121 L 844.8 44.341 L 844.8 14.78 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.24" d="M 921.6 0 L 947.2 14.78 L 947.2 44.341 L 921.6 59.121 L 896 44.341 L 896 14.78 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 972.8 0 L 998.4 14.78 L 998.4 44.341 L 972.8 59.121 L 947.2 44.341 L 947.2 14.78 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.86" d="M 1024 0 L 1049.6 14.78 L 1049.6 44.341 L 1024 59.121 L 998.4 44.341 L 998.4 14.78 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="1" d="M 25.6 44.341 L 51.2 59.121 L 51.2 88.681 L 25.6 103.461 L 0 88.681 L 0 59.121 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.48" d="M 76.8 44.341 L 102.4 59.121 L 102.4 88.681 L 76.8 103.461 L 51.2 88.681 L 51.2 59.121 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.88" d="M 128 44.341 L 153.6 59.121 L 153.6 88.681 L 128 103.461 L 102.4 88.681 L 102.4 59.121 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 179.2 44.341 L 204.8 59.121 L 204.8 88.681 L 179.2 103.461 L 153.6 88.681 L 153.6 59.121 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 230.4 44.341 L 256 59.121 L 256 88.681 L 230.4 103.461 L 204.8 88.681 L 204.8 59.121 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 281.6 44.341 L 307.2 59.121 L 307.2 88.681 L 281.6 103.461 L 256 88.681 L 256 59.121 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.67" d="M 332.8 44.341 L 358.4 59.121 L 358.4 88.681 L 332.8 103.461 L 307.2 88.681 L 307.2 59.121 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.51" d="M 384 44.341 L 409.6 59.121 L 409.6 88.681 L 384 103.461 L 358.4 88.681 L 358.4 59.121 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.83" d="M 435.2 44.341 L 460.8 59.121 L 460.8 88.681 L 435.2 103.461 L 409.6 88.681 L 409.6 59.121 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.4" d="M 486.4 44.341 L 512 59.121 L 512 88.681 L 486.4 103.461 L 460.8 88.681 L 460.8 59.121 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 537.6 44.341 L 563.2 59.121 L 563.2 88.681 L 537.6 103.461 L 512 88.681 L 512 59.121 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.77" d="M 588.8 44.341 L 614.4 59.121 L 614.4 88.681 L 588.8 103.461 L 563.2 88.681 L 563.2 59.121 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.4" d="M 640 44.341 L 665.6 59.121 L 665.6 88.681 L 640 103.461 L 614.4 88.681 L 614.4 59.121 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.64" d="M 691.2 44.341 L 716.8 59.121 L 716.8 88.681 L 691.2 103.461 L 665.6 88.681 L 665.6 59.121 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.22" d="M 742.4 44.341 L 768 59.121 L 768 88.681 L 742.4 103.461 L 716.8 88.681 L 716.8 59.121 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.32" d="M 793.6 44.341 L 819.2 59.121 L 819.2 88.681 L 793.6 103.461 L 768 88.681 L 768 59.121 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.37" d="M 844.8 44.341 L 870.4 59.121 L 870.4 88.681 L 844.8 103.461 L 819.2 88.681 L 819.2 59.121 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 896 44.341 L 921.6 59.121 L 921.6 88.681 L 896 103.461 L 870.4 88.681 L 870.4 59.121 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 947.2 44.341 L 972.8 59.121 L 972.8 88.681 L 947.2 103.461 L 921.6 88.681 L 921.6 59.121 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 998.4 44.341 L 1024 59.121 L 1024 88.681 L 998.4 103.461 L 972.8 88.681 L 972.8 59.121 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.91" d="M 1049.6 44.341 L 1075.2 59.121 L 1075.2 88.681 L 1049.6 103.461 L 1024 88.681 L 1024 59.121 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.82" d="M 0 88.681 L 25.6 103.461 L 25.6 133.022 L 0 147.802 L -25.6 133.022 L -25.6 103.461 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.47" d="M 51.2 88.681 L 76.8 103.461 L 76.8 133.022 L 51.2 147.802 L 25.6 133.022 L 25.6 103.461 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 102.4 88.681 L 128 103.461 L 128 133.022 L 102.4 147.802 L 76.8 133.022 L 76.8 103.461 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.47" d="M 153.6 88.681 L 179.2 103.461 L 179.2 133.022 L 153.6 147.802 L 128 133.022 L 128 103.461 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 204.8 88.681 L 230.4 103.461 L 230.4 133.022 L 204.8 147.802 L 179.2 133.022 L 179.2 103.461 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.78" d="M 256 88.681 L 281.6 103.461 L 281.6 133.022 L 256 147.802 L 230.4 133.022 L 230.4 103.461 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 307.2 88.681 L 332.8 103.461 L 332.8 133.022 L 307.2 147.802 L 281.6 133.022 L 281.6 103.461 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.86" d="M 358.4 88.681 L 384 103.461 L 384 133.022 L 358.4 147.802 L 332.8 133.022 L 332.8 103.461 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.69" d="M 409.6 88.681 L 435.2 103.461 L 435.2 133.022 L 409.6 147.802 L 384 133.022 L 384 103.461 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.94" d="M 460.8 88.681 L 486.4 103.461 L 486.4 133.022 L 460.8 147.802 L 435.2 133.022 L 435.2 103.461 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 512 88.681 L 537.6 103.461 L 537.6 133.022 L 512 147.802 L 486.4 133.022 L 486.4 103.461 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 563.2 88.681 L 588.8 103.461 L 588.8 133.022 L 563.2 147.802 L 537.6 133.022 L 537.6 103.461 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.31" d="M 614.4 88.681 L 640 103.461 L 640 133.022 L 614.4 147.802 L 588.8 133.022 L 588.8 103.461 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 665.6 88.681 L 691.2 103.461 L 691.2 133.022 L 665.6 147.802 L 640 133.022 L 640 103.461 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.65" d="M 716.8 88.681 L 742.4 103.461 L 742.4 133.022 L 716.8 147.802 L 691.2 133.022 L 691.2 103.461 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.22" d="M 768 88.681 L 793.6 103.461 L 793.6 133.022 L 768 147.802 L 742.4 133.022 L 742.4 103.461 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.5" d="M 819.2 88.681 L 844.8 103.461 L 844.8 133.022 L 819.2 147.802 L 793.6 133.022 L 793.6 103.461 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 870.4 88.681 L 896 103.461 L 896 133.022 L 870.4 147.802 L 844.8 133.022 L 844.8 103.461 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.71" d="M 921.6 88.681 L 947.2 103.461 L 947.2 133.022 L 921.6 147.802 L 896 133.022 L 896 103.461 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.66" d="M 972.8 88.681 L 998.4 103.461 L 998.4 133.022 L 972.8 147.802 L 947.2 133.022 L 947.2 103.461 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 1024 88.681 L 1049.6 103.461 L 1049.6 133.022 L 1024 147.802 L 998.4 133.022 L 998.4 103.461 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.59" d="M 25.6 133.022 L 51.2 147.802 L 51.2 177.362 L 25.6 192.142 L 0 177.362 L 0 147.802 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 76.8 133.022 L 102.4 147.802 L 102.4 177.362 L 76.8 192.142 L 51.2 177.362 L 51.2 147.802 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.68" d="M 128 133.022 L 153.6 147.802 L 153.6 177.362 L 128 192.142 L 102.4 177.362 L 102.4 147.802 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 179.2 133.022 L 204.8 147.802 L 204.8 177.362 L 179.2 192.142 L 153.6 177.362 L 153.6 147.802 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 230.4 133.022 L 256 147.802 L 256 177.362 L 230.4 192.142 L 204.8 177.362 L 204.8 147.802 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 281.6 133.022 L 307.2 147.802 L 307.2 177.362 L 281.6 192.142 L 256 177.362 L 256 147.802 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.34" d="M 332.8 133.022 L 358.4 147.802 L 358.4 177.362 L 332.8 192.142 L 307.2 177.362 L 307.2 147.802 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 384 133.022 L 409.6 147.802 L 409.6 177.362 L 384 192.142 L 358.4 177.362 L 358.4 147.802 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.31" d="M 435.2 133.022 L 460.8 147.802 L 460.8 177.362 L 435.2 192.142 L 409.6 177.362 L 409.6 147.802 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.36" d="M 486.4 133.022 L 512 147.802 L 512 177.362 L 486.4 192.142 L 460.8 177.362 L 460.8 147.802 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.72" d="M 537.6 133.022 L 563.2 147.802 L 563.2 177.362 L 537.6 192.142 L 512 177.362 L 512 147.802 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.27" d="M 588.8 133.022 L 614.4 147.802 L 614.4 177.362 L 588.8 192.142 L 563.2 177.362 L 563.2 147.802 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 640 133.022 L 665.6 147.802 L 665.6 177.362 L 640 192.142 L 614.4 177.362 L 614.4 147.802 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.95" d="M 691.2 133.022 L 716.8 147.802 L 716.8 177.362 L 691.2 192.142 L 665.6 177.362 L 665.6 147.802 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.58" d="M 742.4 133.022 L 768 147.802 L 768 177.362 L 742.4 192.142 L 716.8 177.362 L 716.8 147.802 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.54" d="M 793.6 133.022 L 819.2 147.802 L 819.2 177.362 L 793.6 192.142 L 768 177.362 L 768 147.802 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.56" d="M 844.8 133.022 L 870.4 147.802 L 870.4 177.362 L 844.8 192.142 L 819.2 177.362 L 819.2 147.802 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.44" d="M 896 133.022 L 921.6 147.802 L 921.6 177.362 L 896 192.142 L 870.4 177.362 L 870.4 147.802 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.53" d="M 947.2 133.022 L 972.8 147.802 L 972.8 177.362 L 947.2 192.142 L 921.6 177.362 L 921.6 147.802 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 998.4 133.022 L 1024 147.802 L 1024 177.362 L 998.4 192.142 L 972.8 177.362 L 972.8 147.802 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 1049.6 133.022 L 1075.2 147.802 L 1075.2 177.362 L 1049.6 192.142 L 1024 177.362 L 1024 147.802 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.26" d="M 0 177.362 L 25.6 192.142 L 25.6 221.703 L 0 236.483 L -25.6 221.703 L -25.6 192.142 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 51.2 177.362 L 76.8 192.142 L 76.8 221.703 L 51.2 236.483 L 25.6 221.703 L 25.6 192.142 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.94" d="M 102.4 177.362 L 128 192.142 L 128 221.703 L 102.4 236.483 L 76.8 221.703 L 76.8 192.142 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 153.6 177.362 L 179.2 192.142 L 179.2 221.703 L 153.6 236.483 L 128 221.703 L 128 192.142 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.39" d="M 204.8 177.362 L 230.4 192.142 L 230.4 221.703 L 204.8 236.483 L 179.2 221.703 L 179.2 192.142 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 256 177.362 L 281.6 192.142 L 281.6 221.703 L 256 236.483 L 230.4 221.703 L 230.4 192.142 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.69" d="M 307.2 177.362 L 332.8 192.142 L 332.8 221.703 L 307.2 236.483 L 281.6 221.703 L 281.6 192.142 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.42" d="M 358.4 177.362 L 384 192.142 L 384 221.703 L 358.4 236.483 L 332.8 221.703 L 332.8 192.142 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.51" d="M 409.6 177.362 L 435.2 192.142 L 435.2 221.703 L 409.6 236.483 L 384 221.703 L 384 192.142 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 460.8 177.362 L 486.4 192.142 L 486.4 221.703 L 460.8 236.483 L 435.2 221.703 L 435.2 192.142 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.46" d="M 512 177.362 L 537.6 192.142 L 537.6 221.703 L 512 236.483 L 486.4 221.703 L 486.4 192.142 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 563.2 177.362 L 588.8 192.142 L 588.8 221.703 L 563.2 236.483 L 537.6 221.703 L 537.6 192.142 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.45" d="M 614.4 177.362 L 640 192.142 L 640 221.703 L 614.4 236.483 L 588.8 221.703 L 588.8 192.142 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.35" d="M 665.6 177.362 L 691.2 192.142 L 691.2 221.703 L 665.6 236.483 L 640 221.703 L 640 192.142 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.77" d="M 716.8 177.362 L 742.4 192.142 L 742.4 221.703 L 716.8 236.483 L 691.2 221.703 L 691.2 192.142 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 768 177.362 L 793.6 192.142 L 793.6 221.703 L 768 236.483 L 742.4 221.703 L 742.4 192.142 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.44" d="M 819.2 177.362 L 844.8 192.142 L 844.8 221.703 L 819.2 236.483 L 793.6 221.703 L 793.6 192.142 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 870.4 177.362 L 896 192.142 L 896 221.703 L 870.4 236.483 L 844.8 221.703 L 844.8 192.142 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 921.6 177.362 L 947.2 192.142 L 947.2 221.703 L 921.6 236.483 L 896 221.703 L 896 192.142 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.44" d="M 972.8 177.362 L 998.4 192.142 L 998.4 221.703 L 972.8 236.483 L 947.2 221.703 L 947.2 192.142 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 1024 177.362 L 1049.6 192.142 L 1049.6 221.703 L 1024 236.483 L 998.4 221.703 L 998.4 192.142 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 25.6 221.703 L 51.2 236.483 L 51.2 266.043 L 25.6 280.823 L 0 266.043 L 0 236.483 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.47" d="M 76.8 221.703 L 102.4 236.483 L 102.4 266.043 L 76.8 280.823 L 51.2 266.043 L 51.2 236.483 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.97" d="M 128 221.703 L 153.6 236.483 L 153.6 266.043 L 128 280.823 L 102.4 266.043 L 102.4 236.483 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 179.2 221.703 L 204.8 236.483 L 204.8 266.043 L 179.2 280.823 L 153.6 266.043 L 153.6 236.483 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.58" d="M 230.4 221.703 L 256 236.483 L 256 266.043 L 230.4 280.823 L 204.8 266.043 L 204.8 236.483 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.77" d="M 281.6 221.703 L 307.2 236.483 L 307.2 266.043 L 281.6 280.823 L 256 266.043 L 256 236.483 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.24" d="M 332.8 221.703 L 358.4 236.483 L 358.4 266.043 L 332.8 280.823 L 307.2 266.043 L 307.2 236.483 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.85" d="M 384 221.703 L 409.6 236.483 L 409.6 266.043 L 384 280.823 L 358.4 266.043 L 358.4 236.483 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.23" d="M 435.2 221.703 L 460.8 236.483 L 460.8 266.043 L 435.2 280.823 L 409.6 266.043 L 409.6 236.483 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.22" d="M 486.4 221.703 L 512 236.483 L 512 266.043 L 486.4 280.823 L 460.8 266.043 L 460.8 236.483 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 537.6 221.703 L 563.2 236.483 L 563.2 266.043 L 537.6 280.823 L 512 266.043 L 512 236.483 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.32" d="M 588.8 221.703 L 614.4 236.483 L 614.4 266.043 L 588.8 280.823 L 563.2 266.043 L 563.2 236.483 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.24" d="M 640 221.703 L 665.6 236.483 L 665.6 266.043 L 640 280.823 L 614.4 266.043 L 614.4 236.483 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 691.2 221.703 L 716.8 236.483 L 716.8 266.043 L 691.2 280.823 L 665.6 266.043 L 665.6 236.483 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 742.4 221.703 L 768 236.483 L 768 266.043 L 742.4 280.823 L 716.8 266.043 L 716.8 236.483 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.42" d="M 793.6 221.703 L 819.2 236.483 L 819.2 266.043 L 793.6 280.823 L 768 266.043 L 768 236.483 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 844.8 221.703 L 870.4 236.483 L 870.4 266.043 L 844.8 280.823 L 819.2 266.043 L 819.2 236.483 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 896 221.703 L 921.6 236.483 L 921.6 266.043 L 896 280.823 L 870.4 266.043 L 870.4 236.483 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 947.2 221.703 L 972.8 236.483 L 972.8 266.043 L 947.2 280.823 L 921.6 266.043 L 921.6 236.483 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.81" d="M 998.4 221.703 L 1024 236.483 L 1024 266.043 L 998.4 280.823 L 972.8 266.043 L 972.8 236.483 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.32" d="M 1049.6 221.703 L 1075.2 236.483 L 1075.2 266.043 L 1049.6 280.823 L 1024 266.043 L 1024 236.483 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.22" d="M 0 266.043 L 25.6 280.823 L 25.6 310.384 L 0 325.164 L -25.6 310.384 L -25.6 280.823 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.73" d="M 51.2 266.043 L 76.8 280.823 L 76.8 310.384 L 51.2 325.164 L 25.6 310.384 L 25.6 280.823 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.28" d="M 102.4 266.043 L 128 280.823 L 128 310.384 L 102.4 325.164 L 76.8 310.384 L 76.8 280.823 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 153.6 266.043 L 179.2 280.823 L 179.2 310.384 L 153.6 325.164 L 128 310.384 L 128 280.823 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.49" d="M 204.8 266.043 L 230.4 280.823 L 230.4 310.384 L 204.8 325.164 L 179.2 310.384 L 179.2 280.823 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.39" d="M 256 266.043 L 281.6 280.823 L 281.6 310.384 L 256 325.164 L 230.4 310.384 L 230.4 280.823 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 307.2 266.043 L 332.8 280.823 L 332.8 310.384 L 307.2 325.164 L 281.6 310.384 L 281.6 280.823 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.72" d="M 358.4 266.043 L 384 280.823 L 384 310.384 L 358.4 325.164 L 332.8 310.384 L 332.8 280.823 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.38" d="M 409.6 266.043 L 435.2 280.823 L 435.2 310.384 L 409.6 325.164 L 384 310.384 L 384 280.823 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.64" d="M 460.8 266.043 L 486.4 280.823 L 486.4 310.384 L 460.8 325.164 L 435.2 310.384 L 435.2 280.823 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.68" d="M 512 266.043 L 537.6 280.823 L 537.6 310.384 L 512 325.164 L 486.4 310.384 L 486.4 280.823 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 563.2 266.043 L 588.8 280.823 L 588.8 310.384 L 563.2 325.164 L 537.6 310.384 L 537.6 280.823 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 614.4 266.043 L 640 280.823 L 640 310.384 L 614.4 325.164 L 588.8 310.384 L 588.8 280.823 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 665.6 266.043 L 691.2 280.823 L 691.2 310.384 L 665.6 325.164 L 640 310.384 L 640 280.823 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.23" d="M 716.8 266.043 L 742.4 280.823 L 742.4 310.384 L 716.8 325.164 L 691.2 310.384 L 691.2 280.823 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 768 266.043 L 793.6 280.823 L 793.6 310.384 L 768 325.164 L 742.4 310.384 L 742.4 280.823 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 819.2 266.043 L 844.8 280.823 L 844.8 310.384 L 819.2 325.164 L 793.6 310.384 L 793.6 280.823 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 870.4 266.043 L 896 280.823 L 896 310.384 L 870.4 325.164 L 844.8 310.384 L 844.8 280.823 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 921.6 266.043 L 947.2 280.823 L 947.2 310.384 L 921.6 325.164 L 896 310.384 L 896 280.823 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.31" d="M 972.8 266.043 L 998.4 280.823 L 998.4 310.384 L 972.8 325.164 L 947.2 310.384 L 947.2 280.823 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.33" d="M 1024 266.043 L 1049.6 280.823 L 1049.6 310.384 L 1024 325.164 L 998.4 310.384 L 998.4 280.823 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.44" d="M 25.6 310.384 L 51.2 325.164 L 51.2 354.724 L 25.6 369.504 L 0 354.724 L 0 325.164 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 76.8 310.384 L 102.4 325.164 L 102.4 354.724 L 76.8 369.504 L 51.2 354.724 L 51.2 325.164 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.32" d="M 128 310.384 L 153.6 325.164 L 153.6 354.724 L 128 369.504 L 102.4 354.724 L 102.4 325.164 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 179.2 310.384 L 204.8 325.164 L 204.8 354.724 L 179.2 369.504 L 153.6 354.724 L 153.6 325.164 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 230.4 310.384 L 256 325.164 L 256 354.724 L 230.4 369.504 L 204.8 354.724 L 204.8 325.164 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.46" d="M 281.6 310.384 L 307.2 325.164 L 307.2 354.724 L 281.6 369.504 L 256 354.724 L 256 325.164 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 332.8 310.384 L 358.4 325.164 L 358.4 354.724 L 332.8 369.504 L 307.2 354.724 L 307.2 325.164 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 384 310.384 L 409.6 325.164 L 409.6 354.724 L 384 369.504 L 358.4 354.724 L 358.4 325.164 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.62" d="M 435.2 310.384 L 460.8 325.164 L 460.8 354.724 L 435.2 369.504 L 409.6 354.724 L 409.6 325.164 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.59" d="M 486.4 310.384 L 512 325.164 L 512 354.724 L 486.4 369.504 L 460.8 354.724 L 460.8 325.164 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 537.6 310.384 L 563.2 325.164 L 563.2 354.724 L 537.6 369.504 L 512 354.724 L 512 325.164 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 588.8 310.384 L 614.4 325.164 L 614.4 354.724 L 588.8 369.504 L 563.2 354.724 L 563.2 325.164 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.81" d="M 640 310.384 L 665.6 325.164 L 665.6 354.724 L 640 369.504 L 614.4 354.724 L 614.4 325.164 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.52" d="M 691.2 310.384 L 716.8 325.164 L 716.8 354.724 L 691.2 369.504 L 665.6 354.724 L 665.6 325.164 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.73" d="M 742.4 310.384 L 768 325.164 L 768 354.724 L 742.4 369.504 L 716.8 354.724 L 716.8 325.164 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.32" d="M 793.6 310.384 L 819.2 325.164 L 819.2 354.724 L 793.6 369.504 L 768 354.724 L 768 325.164 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.34" d="M 844.8 310.384 L 870.4 325.164 L 870.4 354.724 L 844.8 369.504 L 819.2 354.724 L 819.2 325.164 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.61" d="M 896 310.384 L 921.6 325.164 L 921.6 354.724 L 896 369.504 L 870.4 354.724 L 870.4 325.164 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 947.2 310.384 L 972.8 325.164 L 972.8 354.724 L 947.2 369.504 L 921.6 354.724 L 921.6 325.164 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 998.4 310.384 L 1024 325.164 L 1024 354.724 L 998.4 369.504 L 972.8 354.724 L 972.8 325.164 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 1049.6 310.384 L 1075.2 325.164 L 1075.2 354.724 L 1049.6 369.504 L 1024 354.724 L 1024 325.164 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.84" d="M 0 354.724 L 25.6 369.504 L 25.6 399.065 L 0 413.845 L -25.6 399.065 L -25.6 369.504 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.37" d="M 51.2 354.724 L 76.8 369.504 L 76.8 399.065 L 51.2 413.845 L 25.6 399.065 L 25.6 369.504 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 102.4 354.724 L 128 369.504 L 128 399.065 L 102.4 413.845 L 76.8 399.065 L 76.8 369.504 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 153.6 354.724 L 179.2 369.504 L 179.2 399.065 L 153.6 413.845 L 128 399.065 L 128 369.504 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 204.8 354.724 L 230.4 369.504 L 230.4 399.065 L 204.8 413.845 L 179.2 399.065 L 179.2 369.504 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.54" d="M 256 354.724 L 281.6 369.504 L 281.6 399.065 L 256 413.845 L 230.4 399.065 L 230.4 369.504 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 307.2 354.724 L 332.8 369.504 L 332.8 399.065 L 307.2 413.845 L 281.6 399.065 L 281.6 369.504 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 358.4 354.724 L 384 369.504 L 384 399.065 L 358.4 413.845 L 332.8 399.065 L 332.8 369.504 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.43" d="M 409.6 354.724 L 435.2 369.504 L 435.2 399.065 L 409.6 413.845 L 384 399.065 L 384 369.504 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 460.8 354.724 L 486.4 369.504 L 486.4 399.065 L 460.8 413.845 L 435.2 399.065 L 435.2 369.504 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.5" d="M 512 354.724 L 537.6 369.504 L 537.6 399.065 L 512 413.845 L 486.4 399.065 L 486.4 369.504 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.22" d="M 563.2 354.724 L 588.8 369.504 L 588.8 399.065 L 563.2 413.845 L 537.6 399.065 L 537.6 369.504 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.24" d="M 614.4 354.724 L 640 369.504 L 640 399.065 L 614.4 413.845 L 588.8 399.065 L 588.8 369.504 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.24" d="M 665.6 354.724 L 691.2 369.504 L 691.2 399.065 L 665.6 413.845 L 640 399.065 L 640 369.504 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.67" d="M 716.8 354.724 L 742.4 369.504 L 742.4 399.065 L 716.8 413.845 L 691.2 399.065 L 691.2 369.504 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 768 354.724 L 793.6 369.504 L 793.6 399.065 L 768 413.845 L 742.4 399.065 L 742.4 369.504 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.82" d="M 819.2 354.724 L 844.8 369.504 L 844.8 399.065 L 819.2 413.845 L 793.6 399.065 L 793.6 369.504 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.86" d="M 870.4 354.724 L 896 369.504 L 896 399.065 L 870.4 413.845 L 844.8 399.065 L 844.8 369.504 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.22" d="M 921.6 354.724 L 947.2 369.504 L 947.2 399.065 L 921.6 413.845 L 896 399.065 L 896 369.504 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.75" d="M 972.8 354.724 L 998.4 369.504 L 998.4 399.065 L 972.8 413.845 L 947.2 399.065 L 947.2 369.504 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 1024 354.724 L 1049.6 369.504 L 1049.6 399.065 L 1024 413.845 L 998.4 399.065 L 998.4 369.504 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 25.6 399.065 L 51.2 413.845 L 51.2 443.405 L 25.6 458.185 L 0 443.405 L 0 413.845 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.54" d="M 76.8 399.065 L 102.4 413.845 L 102.4 443.405 L 76.8 458.185 L 51.2 443.405 L 51.2 413.845 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 128 399.065 L 153.6 413.845 L 153.6 443.405 L 128 458.185 L 102.4 443.405 L 102.4 413.845 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.33" d="M 179.2 399.065 L 204.8 413.845 L 204.8 443.405 L 179.2 458.185 L 153.6 443.405 L 153.6 413.845 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.55" d="M 230.4 399.065 L 256 413.845 L 256 443.405 L 230.4 458.185 L 204.8 443.405 L 204.8 413.845 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 281.6 399.065 L 307.2 413.845 L 307.2 443.405 L 281.6 458.185 L 256 443.405 L 256 413.845 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.99" d="M 332.8 399.065 L 358.4 413.845 L 358.4 443.405 L 332.8 458.185 L 307.2 443.405 L 307.2 413.845 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.44" d="M 384 399.065 L 409.6 413.845 L 409.6 443.405 L 384 458.185 L 358.4 443.405 L 358.4 413.845 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 435.2 399.065 L 460.8 413.845 L 460.8 443.405 L 435.2 458.185 L 409.6 443.405 L 409.6 413.845 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.64" d="M 486.4 399.065 L 512 413.845 L 512 443.405 L 486.4 458.185 L 460.8 443.405 L 460.8 413.845 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 537.6 399.065 L 563.2 413.845 L 563.2 443.405 L 537.6 458.185 L 512 443.405 L 512 413.845 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 588.8 399.065 L 614.4 413.845 L 614.4 443.405 L 588.8 458.185 L 563.2 443.405 L 563.2 413.845 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 640 399.065 L 665.6 413.845 L 665.6 443.405 L 640 458.185 L 614.4 443.405 L 614.4 413.845 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.63" d="M 691.2 399.065 L 716.8 413.845 L 716.8 443.405 L 691.2 458.185 L 665.6 443.405 L 665.6 413.845 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.46" d="M 742.4 399.065 L 768 413.845 L 768 443.405 L 742.4 458.185 L 716.8 443.405 L 716.8 413.845 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.82" d="M 793.6 399.065 L 819.2 413.845 L 819.2 443.405 L 793.6 458.185 L 768 443.405 L 768 413.845 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.8" d="M 844.8 399.065 L 870.4 413.845 L 870.4 443.405 L 844.8 458.185 L 819.2 443.405 L 819.2 413.845 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 896 399.065 L 921.6 413.845 L 921.6 443.405 L 896 458.185 L 870.4 443.405 L 870.4 413.845 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.47" d="M 947.2 399.065 L 972.8 413.845 L 972.8 443.405 L 947.2 458.185 L 921.6 443.405 L 921.6 413.845 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 998.4 399.065 L 1024 413.845 L 1024 443.405 L 998.4 458.185 L 972.8 443.405 L 972.8 413.845 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.41" d="M 1049.6 399.065 L 1075.2 413.845 L 1075.2 443.405 L 1049.6 458.185 L 1024 443.405 L 1024 413.845 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.3" d="M 0 443.405 L 25.6 458.185 L 25.6 487.746 L 0 502.526 L -25.6 487.746 L -25.6 458.185 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.61" d="M 51.2 443.405 L 76.8 458.185 L 76.8 487.746 L 51.2 502.526 L 25.6 487.746 L 25.6 458.185 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 102.4 443.405 L 128 458.185 L 128 487.746 L 102.4 502.526 L 76.8 487.746 L 76.8 458.185 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.21" d="M 153.6 443.405 L 179.2 458.185 L 179.2 487.746 L 153.6 502.526 L 128 487.746 L 128 458.185 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.72" d="M 204.8 443.405 L 230.4 458.185 L 230.4 487.746 L 204.8 502.526 L 179.2 487.746 L 179.2 458.185 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.31" d="M 256 443.405 L 281.6 458.185 L 281.6 487.746 L 256 502.526 L 230.4 487.746 L 230.4 458.185 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 307.2 443.405 L 332.8 458.185 L 332.8 487.746 L 307.2 502.526 L 281.6 487.746 L 281.6 458.185 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 358.4 443.405 L 384 458.185 L 384 487.746 L 358.4 502.526 L 332.8 487.746 L 332.8 458.185 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.7" d="M 409.6 443.405 L 435.2 458.185 L 435.2 487.746 L 409.6 502.526 L 384 487.746 L 384 458.185 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.3" d="M 460.8 443.405 L 486.4 458.185 L 486.4 487.746 L 460.8 502.526 L 435.2 487.746 L 435.2 458.185 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.21" d="M 512 443.405 L 537.6 458.185 L 537.6 487.746 L 512 502.526 L 486.4 487.746 L 486.4 458.185 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 563.2 443.405 L 588.8 458.185 L 588.8 487.746 L 563.2 502.526 L 537.6 487.746 L 537.6 458.185 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 614.4 443.405 L 640 458.185 L 640 487.746 L 614.4 502.526 L 588.8 487.746 L 588.8 458.185 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.48" d="M 665.6 443.405 L 691.2 458.185 L 691.2 487.746 L 665.6 502.526 L 640 487.746 L 640 458.185 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 716.8 443.405 L 742.4 458.185 L 742.4 487.746 L 716.8 502.526 L 691.2 487.746 L 691.2 458.185 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 768 443.405 L 793.6 458.185 L 793.6 487.746 L 768 502.526 L 742.4 487.746 L 742.4 458.185 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.64" d="M 819.2 443.405 L 844.8 458.185 L 844.8 487.746 L 819.2 502.526 L 793.6 487.746 L 793.6 458.185 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.76" d="M 870.4 443.405 L 896 458.185 L 896 487.746 L 870.4 502.526 L 844.8 487.746 L 844.8 458.185 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 921.6 443.405 L 947.2 458.185 L 947.2 487.746 L 921.6 502.526 L 896 487.746 L 896 458.185 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.87" d="M 972.8 443.405 L 998.4 458.185 L 998.4 487.746 L 972.8 502.526 L 947.2 487.746 L 947.2 458.185 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 1024 443.405 L 1049.6 458.185 L 1049.6 487.746 L 1024 502.526 L 998.4 487.746 L 998.4 458.185 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.37" d="M 25.6 487.746 L 51.2 502.526 L 51.2 532.086 L 25.6 546.866 L 0 532.086 L 0 502.526 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.35" d="M 76.8 487.746 L 102.4 502.526 L 102.4 532.086 L 76.8 546.866 L 51.2 532.086 L 51.2 502.526 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.3" d="M 128 487.746 L 153.6 502.526 L 153.6 532.086 L 128 546.866 L 102.4 532.086 L 102.4 502.526 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.33" d="M 179.2 487.746 L 204.8 502.526 L 204.8 532.086 L 179.2 546.866 L 153.6 532.086 L 153.6 502.526 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.29" d="M 230.4 487.746 L 256 502.526 L 256 532.086 L 230.4 546.866 L 204.8 532.086 L 204.8 502.526 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.46" d="M 281.6 487.746 L 307.2 502.526 L 307.2 532.086 L 281.6 546.866 L 256 532.086 L 256 502.526 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 332.8 487.746 L 358.4 502.526 L 358.4 532.086 L 332.8 546.866 L 307.2 532.086 L 307.2 502.526 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.3" d="M 384 487.746 L 409.6 502.526 L 409.6 532.086 L 384 546.866 L 358.4 532.086 L 358.4 502.526 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 435.2 487.746 L 460.8 502.526 L 460.8 532.086 L 435.2 546.866 L 409.6 532.086 L 409.6 502.526 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 486.4 487.746 L 512 502.526 L 512 532.086 L 486.4 546.866 L 460.8 532.086 L 460.8 502.526 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.36" d="M 537.6 487.746 L 563.2 502.526 L 563.2 532.086 L 537.6 546.866 L 512 532.086 L 512 502.526 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 588.8 487.746 L 614.4 502.526 L 614.4 532.086 L 588.8 546.866 L 563.2 532.086 L 563.2 502.526 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.26" d="M 640 487.746 L 665.6 502.526 L 665.6 532.086 L 640 546.866 L 614.4 532.086 L 614.4 502.526 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.56" d="M 691.2 487.746 L 716.8 502.526 L 716.8 532.086 L 691.2 546.866 L 665.6 532.086 L 665.6 502.526 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.86" d="M 742.4 487.746 L 768 502.526 L 768 532.086 L 742.4 546.866 L 716.8 532.086 L 716.8 502.526 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.21" d="M 793.6 487.746 L 819.2 502.526 L 819.2 532.086 L 793.6 546.866 L 768 532.086 L 768 502.526 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 844.8 487.746 L 870.4 502.526 L 870.4 532.086 L 844.8 546.866 L 819.2 532.086 L 819.2 502.526 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 896 487.746 L 921.6 502.526 L 921.6 532.086 L 896 546.866 L 870.4 532.086 L 870.4 502.526 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 947.2 487.746 L 972.8 502.526 L 972.8 532.086 L 947.2 546.866 L 921.6 532.086 L 921.6 502.526 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.96" d="M 998.4 487.746 L 1024 502.526 L 1024 532.086 L 998.4 546.866 L 972.8 532.086 L 972.8 502.526 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.91" d="M 1049.6 487.746 L 1075.2 502.526 L 1075.2 532.086 L 1049.6 546.866 L 1024 532.086 L 1024 502.526 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.84" d="M 0 532.086 L 25.6 546.866 L 25.6 576.427 L 0 591.207 L -25.6 576.427 L -25.6 546.866 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.86" d="M 51.2 532.086 L 76.8 546.866 L 76.8 576.427 L 51.2 591.207 L 25.6 576.427 L 25.6 546.866 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.75" d="M 102.4 532.086 L 128 546.866 L 128 576.427 L 102.4 591.207 L 76.8 576.427 L 76.8 546.866 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 153.6 532.086 L 179.2 546.866 L 179.2 576.427 L 153.6 591.207 L 128 576.427 L 128 546.866 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.37" d="M 204.8 532.086 L 230.4 546.866 L 230.4 576.427 L 204.8 591.207 L 179.2 576.427 L 179.2 546.866 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.81" d="M 256 532.086 L 281.6 546.866 L 281.6 576.427 L 256 591.207 L 230.4 576.427 L 230.4 546.866 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 307.2 532.086 L 332.8 546.866 L 332.8 576.427 L 307.2 591.207 L 281.6 576.427 L 281.6 546.866 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.81" d="M 358.4 532.086 L 384 546.866 L 384 576.427 L 358.4 591.207 L 332.8 576.427 L 332.8 546.866 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.38" d="M 409.6 532.086 L 435.2 546.866 L 435.2 576.427 L 409.6 591.207 L 384 576.427 L 384 546.866 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.72" d="M 460.8 532.086 L 486.4 546.866 L 486.4 576.427 L 460.8 591.207 L 435.2 576.427 L 435.2 546.866 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.52" d="M 512 532.086 L 537.6 546.866 L 537.6 576.427 L 512 591.207 L 486.4 576.427 L 486.4 546.866 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 563.2 532.086 L 588.8 546.866 L 588.8 576.427 L 563.2 591.207 L 537.6 576.427 L 537.6 546.866 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.29" d="M 614.4 532.086 L 640 546.866 L 640 576.427 L 614.4 591.207 L 588.8 576.427 L 588.8 546.866 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.65" d="M 665.6 532.086 L 691.2 546.866 L 691.2 576.427 L 665.6 591.207 L 640 576.427 L 640 546.866 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 716.8 532.086 L 742.4 546.866 L 742.4 576.427 L 716.8 591.207 L 691.2 576.427 L 691.2 546.866 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.78" d="M 768 532.086 L 793.6 546.866 L 793.6 576.427 L 768 591.207 L 742.4 576.427 L 742.4 546.866 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.3" d="M 819.2 532.086 L 844.8 546.866 L 844.8 576.427 L 819.2 591.207 L 793.6 576.427 L 793.6 546.866 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.29" d="M 870.4 532.086 L 896 546.866 L 896 576.427 L 870.4 591.207 L 844.8 576.427 L 844.8 546.866 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.66" d="M 921.6 532.086 L 947.2 546.866 L 947.2 576.427 L 921.6 591.207 L 896 576.427 L 896 546.866 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.64" d="M 972.8 532.086 L 998.4 546.866 L 998.4 576.427 L 972.8 591.207 L 947.2 576.427 L 947.2 546.866 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.51" d="M 1024 532.086 L 1049.6 546.866 L 1049.6 576.427 L 1024 591.207 L 998.4 576.427 L 998.4 546.866 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 25.6 576.427 L 51.2 591.207 L 51.2 620.767 L 25.6 635.547 L 0 620.767 L 0 591.207 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 76.8 576.427 L 102.4 591.207 L 102.4 620.767 L 76.8 635.547 L 51.2 620.767 L 51.2 591.207 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 128 576.427 L 153.6 591.207 L 153.6 620.767 L 128 635.547 L 102.4 620.767 L 102.4 591.207 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.76" d="M 179.2 576.427 L 204.8 591.207 L 204.8 620.767 L 179.2 635.547 L 153.6 620.767 L 153.6 591.207 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.48" d="M 230.4 576.427 L 256 591.207 L 256 620.767 L 230.4 635.547 L 204.8 620.767 L 204.8 591.207 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 281.6 576.427 L 307.2 591.207 L 307.2 620.767 L 281.6 635.547 L 256 620.767 L 256 591.207 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.21" d="M 332.8 576.427 L 358.4 591.207 L 358.4 620.767 L 332.8 635.547 L 307.2 620.767 L 307.2 591.207 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.36" d="M 384 576.427 L 409.6 591.207 L 409.6 620.767 L 384 635.547 L 358.4 620.767 L 358.4 591.207 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 435.2 576.427 L 460.8 591.207 L 460.8 620.767 L 435.2 635.547 L 409.6 620.767 L 409.6 591.207 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.47" d="M 486.4 576.427 L 512 591.207 L 512 620.767 L 486.4 635.547 L 460.8 620.767 L 460.8 591.207 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 537.6 576.427 L 563.2 591.207 L 563.2 620.767 L 537.6 635.547 L 512 620.767 L 512 591.207 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.53" d="M 588.8 576.427 L 614.4 591.207 L 614.4 620.767 L 588.8 635.547 L 563.2 620.767 L 563.2 591.207 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 640 576.427 L 665.6 591.207 L 665.6 620.767 L 640 635.547 L 614.4 620.767 L 614.4 591.207 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.35" d="M 691.2 576.427 L 716.8 591.207 L 716.8 620.767 L 691.2 635.547 L 665.6 620.767 L 665.6 591.207 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.9" d="M 742.4 576.427 L 768 591.207 L 768 620.767 L 742.4 635.547 L 716.8 620.767 L 716.8 591.207 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 793.6 576.427 L 819.2 591.207 L 819.2 620.767 L 793.6 635.547 L 768 620.767 L 768 591.207 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 844.8 576.427 L 870.4 591.207 L 870.4 620.767 L 844.8 635.547 L 819.2 620.767 L 819.2 591.207 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 896 576.427 L 921.6 591.207 L 921.6 620.767 L 896 635.547 L 870.4 620.767 L 870.4 591.207 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.78" d="M 947.2 576.427 L 972.8 591.207 L 972.8 620.767 L 947.2 635.547 L 921.6 620.767 L 921.6 591.207 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.78" d="M 998.4 576.427 L 1024 591.207 L 1024 620.767 L 998.4 635.547 L 972.8 620.767 L 972.8 591.207 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 1049.6 576.427 L 1075.2 591.207 L 1075.2 620.767 L 1049.6 635.547 L 1024 620.767 L 1024 591.207 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 0 620.767 L 25.6 635.547 L 25.6 665.108 L 0 679.888 L -25.6 665.108 L -25.6 635.547 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.57" d="M 51.2 620.767 L 76.8 635.547 L 76.8 665.108 L 51.2 679.888 L 25.6 665.108 L 25.6 635.547 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.37" d="M 102.4 620.767 L 128 635.547 L 128 665.108 L 102.4 679.888 L 76.8 665.108 L 76.8 635.547 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.94" d="M 153.6 620.767 L 179.2 635.547 L 179.2 665.108 L 153.6 679.888 L 128 665.108 L 128 635.547 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.67" d="M 204.8 620.767 L 230.4 635.547 L 230.4 665.108 L 204.8 679.888 L 179.2 665.108 L 179.2 635.547 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.54" d="M 256 620.767 L 281.6 635.547 L 281.6 665.108 L 256 679.888 L 230.4 665.108 L 230.4 635.547 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.7" d="M 307.2 620.767 L 332.8 635.547 L 332.8 665.108 L 307.2 679.888 L 281.6 665.108 L 281.6 635.547 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.46" d="M 358.4 620.767 L 384 635.547 L 384 665.108 L 358.4 679.888 L 332.8 665.108 L 332.8 635.547 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 409.6 620.767 L 435.2 635.547 L 435.2 665.108 L 409.6 679.888 L 384 665.108 L 384 635.547 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 460.8 620.767 L 486.4 635.547 L 486.4 665.108 L 460.8 679.888 L 435.2 665.108 L 435.2 635.547 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.68" d="M 512 620.767 L 537.6 635.547 L 537.6 665.108 L 512 679.888 L 486.4 665.108 L 486.4 635.547 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 563.2 620.767 L 588.8 635.547 L 588.8 665.108 L 563.2 679.888 L 537.6 665.108 L 537.6 635.547 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 614.4 620.767 L 640 635.547 L 640 665.108 L 614.4 679.888 L 588.8 665.108 L 588.8 635.547 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.25" d="M 665.6 620.767 L 691.2 635.547 L 691.2 665.108 L 665.6 679.888 L 640 665.108 L 640 635.547 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.91" d="M 716.8 620.767 L 742.4 635.547 L 742.4 665.108 L 716.8 679.888 L 691.2 665.108 L 691.2 635.547 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 768 620.767 L 793.6 635.547 L 793.6 665.108 L 768 679.888 L 742.4 665.108 L 742.4 635.547 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.42" d="M 819.2 620.767 L 844.8 635.547 L 844.8 665.108 L 819.2 679.888 L 793.6 665.108 L 793.6 635.547 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.84" d="M 870.4 620.767 L 896 635.547 L 896 665.108 L 870.4 679.888 L 844.8 665.108 L 844.8 635.547 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 921.6 620.767 L 947.2 635.547 L 947.2 665.108 L 921.6 679.888 L 896 665.108 L 896 635.547 Z"/> +<path stroke="#DDD" stroke-width="1" fill="none" d="M 972.8 620.767 L 998.4 635.547 L 998.4 665.108 L 972.8 679.888 L 947.2 665.108 L 947.2 635.547 Z"/> +<path stroke="#DDD" stroke-width="1" fill="#DDD" fill-opacity="0.36" d="M 1024 620.767 L 1049.6 635.547 L 1049.6 665.108 L 1024 679.888 L 998.4 665.108 L 998.4 635.547 Z"/> +</svg> diff --git a/server/web/src/component/layout.tsx b/server/web/src/component/layout.tsx @@ -1,106 +0,0 @@ -// web/src/component/layout.tsx - chrome, badges, paging and failure - -import m from 'mithril'; -// import * as auth from '../util/auth'; -// import { ApiError } from '../util/api'; -// import { ago, stamp } from '../util/format'; - -// export { session } from '../util/auth'; - -// // Mirrors canManageProject on the server. -// export function manages(project: { owner_id?: string | null } | null): boolean { -// const user = auth.session.user; -// if (!user || !project) return false; -// if (user.role === 'admin') return true; -// return project.owner_id != null && project.owner_id === user.id; -// } - -const Link = (href: string, label: string) => { - const at = m.route.get() ?? ''; - return <m.route.Link href={href} class={at.startsWith(href) ? 'active' : ''}>{label}</m.route.Link>; -}; - -export const Chrome: m.Component = { - view(vnode) { - return [ - <header> - <nav> - <span class="brand">conductor</span> - {Link('/jobs', 'jobs')} - {Link('/projects', 'projects')} - {/* {auth.session.user ? Link('/workers', 'workers') : null} - {auth.session.user?.role === 'admin' ? Link('/users', 'users') : null} - {auth.session.user ? Link('/sessions', 'sessions') : null} */} - </nav> - <div class="who"> - {/* {auth.session.user - ? [ - <span>{auth.session.user.username}</span>, - <button class="link" onclick={() => auth.signOut()}>sign out</button>, - ] - : <button class="link" onclick={() => auth.signIn()}>sign in</button>} */} - </div> - </header>, - <main>{vnode.children}</main>, - ]; - }, -}; - -export const Badge: m.Component<{ state: string }> = { - view: ({ attrs }) => <span class={`badge s-${attrs.state}`}>{attrs.state}</span>, -}; - -export const Loading: m.Component = { - view: () => <p class="muted">loading</p>, -}; - -export const Failed: m.Component<{ error: unknown }> = { - view({ attrs }) { - const e = attrs.error as ApiError; - const unauthenticated = e?.name === 'NotAuthenticated'; - return ( - <div class="error"> - <strong>{unauthenticated ? 'Not signed in' : (e?.name ?? 'Error')}</strong> - <p>{e?.message ?? String(attrs.error)}</p> - {/* {unauthenticated ? <button onclick={() => auth.signIn()}>sign in</button> : null} */} - </div> - ); - }, -}; - -// A polled entity that vanishes has to say so, rather than the region it -// was in quietly disappearing. -export const Gone: m.Component<{ what: string }> = { - view: ({ attrs }) => ( - <div class="error"> - <strong>Gone</strong> - <p>{`That ${attrs.what} no longer exists, or is no longer yours to see.`}</p> - </div> - ), -}; - -export const When: m.Component<{ at: number | null }> = { - view: ({ attrs }) => <span class="when" title={stamp(attrs.at)}>{ago(attrs.at)}</span>, -}; - -type PagerAttrs = { offset: number; total: number; limit: number; onmove: (offset: number) => void }; - -export const Pager: m.Component<PagerAttrs> = { - view({ attrs }) { - const { offset, total, limit } = attrs; - if (total <= limit) return null; - - const last = Math.min(offset + limit, total); - return ( - <div class="pager"> - <button disabled={offset === 0} onclick={() => attrs.onmove(Math.max(0, offset - limit))}>previous</button> - <span>{`${total === 0 ? 0 : offset + 1} to ${last} of ${total}`}</span> - <button disabled={last >= total} onclick={() => attrs.onmove(offset + limit)}>next</button> - </div> - ); - }, -}; - -export const Empty: m.Component<{ what: string }> = { - view: ({ attrs }) => <p class="muted">{`No ${attrs.what} to show.`}</p>, -}; diff --git a/server/web/src/main.ts b/server/web/src/main.ts @@ -1,21 +1,17 @@ import m from 'mithril'; -import { Chrome } from './component/layout'; -import {Home} from './screen/home'; -import {LoginPreTenant} from './screen/login-pre-tenant'; - -function wrap(screen: m.Component, needsUser = false): m.RouteResolver { - return { - // onmatch: () => (needsUser && !session.user ? auth.signIn() : undefined), - render: () => m(Chrome, m(screen)), - }; -} +import { Login } from './screen/login'; const routes: m.RouteDefs = { - '/' : wrap(Home) - '/login': wrap(LoginPreTenant) + '/realms/:realm/login': { + render: (vnode) => m(Login, { realm: (vnode.attrs as any).realm }), + }, + '/login': { + render: () => m(Login, {}), + }, + '/': { + onmatch: () => m.route.set('/login'), + }, }; -m.route.prefix = '#!'; -m.route(document.body, '/', routes); - -console.log('hi'); +m.route.prefix = ''; +m.route(document.body, '/login', routes); diff --git a/server/web/src/screen/home.tsx b/server/web/src/screen/home.tsx @@ -1,11 +0,0 @@ -import m from 'mithril'; - -export const Home: m.Component = { - view(vnode: any) { - return ( - <div> - Hi there, this is the home page - </div> - ); - }, -}; diff --git a/server/web/src/screen/login-pre-tenant.tsx b/server/web/src/screen/login-pre-tenant.tsx @@ -1,11 +0,0 @@ -import m from 'mithril'; - -export const LoginPreTenant: m.Component = { - view(vnode: any) { - return ( - <div> - Hi there, this is the pre-tenant login page - </div> - ); - }, -}; diff --git a/server/web/src/screen/login.tsx b/server/web/src/screen/login.tsx @@ -0,0 +1,221 @@ +import m from 'mithril'; +import { getRealm, resolveLogin, signInPassword, type LoginResolution, type RealmInfo } from '../util/api'; + +type Step = 'identifier' | 'password' | 'oidc'; + +interface TextFieldAttrs { + id: string; + label: string; + type?: string; + value: string; + autocomplete?: string; + autofocus?: boolean; + disabled?: boolean; + oninput: (value: string) => void; +} + +const TextField: m.Component<TextFieldAttrs> = { + view({ attrs }) { + return ( + <div class="auth-field"> + <input + class="auth-input" + id={attrs.id} + type={attrs.type || 'text'} + value={attrs.value} + placeholder=" " + autocomplete={attrs.autocomplete} + disabled={attrs.disabled} + oninput={(e: Event) => attrs.oninput((e.target as HTMLInputElement).value)} + oncreate={attrs.autofocus ? ({ dom }: any) => dom.focus() : undefined} + /> + <label for={attrs.id}>{attrs.label}</label> + </div> + ); + }, +}; + +export const Login: m.Component<{ realm?: string }> = { + realmSlug: null as string | null, + realm: null as RealmInfo | null, + step: 'identifier' as Step, + identifier: '', + password: '', + provider: null as LoginResolution['provider'] | null, + error: null as Error | null, + pending: false, + + oninit(this: any, vnode: any) { + this.realmSlug = vnode.attrs.realm ?? null; + this.loadRealm(); + }, + + async loadRealm(this: any) { + this.error = null; + try { + const realm = await getRealm(this.realmSlug); + this.realm = realm; + document.title = realm.display_name; + } catch (e) { + this.error = e as Error; + } + m.redraw(); + }, + + async submitIdentifier(this: any) { + this.pending = true; + this.error = null; + try { + const result = await resolveLogin(this.realmSlug, this.identifier); + this.realm = result.realm; + document.title = result.realm.display_name; + if (result.next === 'oidc' && result.provider) { + this.provider = result.provider; + this.step = 'oidc'; + } else { + this.step = 'password'; + } + } catch (e) { + this.error = e as Error; + } + this.pending = false; + m.redraw(); + }, + + async submitPassword(this: any) { + this.pending = true; + this.error = null; + try { + await signInPassword(this.realmSlug, this.identifier, this.password); + } catch (e) { + this.error = e as Error; + } + this.pending = false; + m.redraw(); + }, + + back(this: any) { + this.step = 'identifier'; + this.password = ''; + this.provider = null; + this.error = null; + m.redraw(); + }, + + view(this: any) { + const branding = this.realm?.branding || {}; + const primary = typeof branding.primary_color === 'string' ? branding.primary_color : '#25A'; + const style = { '--realm-primary': primary } as any; + + if (!this.realm && this.error) { + return ( + <div class="auth-page" style={style}> + <div class="auth-card"> + <h1>Sign in</h1> + <p class="auth-error">{this.error.message}</p> + </div> + </div> + ); + } + + const title = this.realm?.display_name || 'Sign in'; + + return ( + <div class="auth-page" style={style}> + <div class="auth-card"> + {typeof branding.logo_url === 'string' && branding.logo_url ? ( + <img class="auth-logo" src={branding.logo_url} alt={title} /> + ) : null} + <h1>{title}</h1> + + {this.step === 'identifier' ? this.renderIdentifier(title) : null} + {this.step === 'password' ? this.renderPassword() : null} + {this.step === 'oidc' ? this.renderProvider() : null} + </div> + </div> + ); + }, + + renderIdentifier(this: any, title: string) { + return ( + <form + onsubmit={(e: Event) => { + e.preventDefault(); + if (!this.pending && this.identifier.trim()) this.submitIdentifier(); + }} + > + <TextField + id="identifier" + label="Email or username" + value={this.identifier} + autocomplete="username" + autofocus + disabled={this.pending} + oninput={(value: string) => { + this.identifier = value; + }} + /> + {this.error ? <p class="auth-error">{this.error.message}</p> : null} + <div class="auth-actions"> + <button class="auth-button" type="submit" disabled={this.pending || !this.identifier.trim()}> + {this.pending ? 'Checking' : 'Next'} + </button> + </div> + </form> + ); + }, + + renderPassword(this: any) { + return ( + <form + onsubmit={(e: Event) => { + e.preventDefault(); + if (!this.pending) this.submitPassword(); + }} + > + <p class="auth-identifier">{this.identifier}</p> + <TextField + id="password" + label="Password" + type="password" + value={this.password} + autocomplete="current-password" + autofocus + disabled={this.pending} + oninput={(value: string) => { + this.password = value; + }} + /> + {this.error ? <p class="auth-error">{this.error.message}</p> : null} + <div class="auth-actions"> + <button class="auth-link" type="button" onclick={() => this.back()}> + Back + </button> + <button class="auth-button" type="submit" disabled={this.pending || !this.password}> + {this.pending ? 'Signing in' : 'Sign in'} + </button> + </div> + </form> + ); + }, + + renderProvider(this: any) { + const provider = this.provider; + return ( + <div> + <p class="auth-identifier">{this.identifier}</p> + <p class="auth-note"> + {provider ? `This account signs in with ${provider.display_name}.` : 'This account signs in with an identity provider.'} + </p> + <div class="auth-actions"> + <button class="auth-link" type="button" onclick={() => this.back()}> + Back + </button> + <button class="auth-button" type="button" disabled> + Continue + </button> + </div> + </div> + ); + }, +}; diff --git a/server/web/src/util/api.ts b/server/web/src/util/api.ts @@ -0,0 +1,67 @@ +export interface RealmInfo { + slug: string; + display_name: string; + branding: Record<string, string>; +} + +export interface LoginResolution { + realm: RealmInfo; + next: 'password' | 'oidc'; + provider?: { + slug: string; + display_name: string; + }; +} + +export interface ApiError extends Error { + code?: string; + status?: number; +} + +async function request<T>(path: string, options: RequestInit = {}): Promise<T> { + const response = await fetch(path, { + headers: { + 'content-type': 'application/json', + }, + ...options, + }); + + let payload: any = null; + try { + payload = await response.json(); + } catch { + payload = null; + } + + if (!response.ok) { + const error = new Error(payload?.error?.message || response.statusText) as ApiError; + error.code = payload?.error?.code; + error.status = response.status; + throw error; + } + + return payload as T; +} + +export function getRealm(realm?: string | null): Promise<RealmInfo> { + const query = realm ? `?realm=${encodeURIComponent(realm)}` : ''; + return request<RealmInfo>(`/api/v1/realm${query}`); +} + +export function resolveLogin(realm: string | null, identifier: string): Promise<LoginResolution> { + return request<LoginResolution>('/api/v1/login/resolve', { + method: 'POST', + body: JSON.stringify({ realm: realm || undefined, identifier }), + }); +} + +export function signInPassword( + realm: string | null, + identifier: string, + password: string +): Promise<Record<string, unknown>> { + return request('/api/v1/login/password', { + method: 'POST', + body: JSON.stringify({ realm: realm || undefined, identifier, password }), + }); +}