commit 1fb2e00ec0d6a24763d828d7465876c3d03a5603
parent b73e4d0d1864eae600baf99db471ee0f0803c68d
Author: finwo <finwo@pm.me>
Date: Tue, 20 Oct 2026 01:04:16 +0200
Init login page styling
Diffstat:
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 }),
+ });
+}