splatone 0.0.22 → 0.0.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.API_KEY.Flickr +1 -0
- package/README.md +60 -11
- package/browse.js +8 -0
- package/color.js +360 -4
- package/crawler.js +448 -131
- package/package.json +3 -2
- package/plugins/flickr/worker.js +6 -27
- package/public/out/.gitkeep +0 -0
- package/public/out/result.nzzxvl24mi420u0v.json +1 -0
- package/public/out/voronoi/.gitkeep +0 -0
- package/publication/README.md +18 -0
- package/publication/main.tex +85 -0
- package/publication/references.bib +6 -0
- package/views/index.ejs +686 -343
- package/.vscode/mcp.json +0 -12
- package/.vscode/settings.json +0 -7
- package/assets/icon_data_export.png +0 -0
- package/assets/icon_image_download.png +0 -0
- package/assets/screenshot_florida_hex_majorityr.png +0 -0
- package/assets/screenshot_massive_points_bulky.png +0 -0
- package/assets/screenshot_pie_tokyo.png +0 -0
- package/assets/screenshot_sea-mountain_bulky.png +0 -0
- package/assets/screenshot_venice_heat.png +0 -0
- package/assets/screenshot_venice_marker-cluster.png +0 -0
- package/assets/screenshot_venice_simple.png +0 -0
- package/assets/screenshot_voronoi_tokyo.png +0 -0
package/.API_KEY.Flickr
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
469d6cabae53805a18b68d5d56fd9738
|
package/README.md
CHANGED
|
@@ -18,6 +18,16 @@ SNSのジオタグ付きポストをキーワードに基づいて収集する
|
|
|
18
18
|
|
|
19
19
|
## Change Log
|
|
20
20
|
|
|
21
|
+
### v0.0.22 → → v0.0.23
|
|
22
|
+
|
|
23
|
+
* ブラウズモードの追加
|
|
24
|
+
* ダウンロードした結果ファイルを閲覧するモード
|
|
25
|
+
* ハンバーガーメニューの拡充
|
|
26
|
+
* 結果の統計情報の追加
|
|
27
|
+
* CLIコマンドの表示
|
|
28
|
+
* カラーパレット生成ツールの改良
|
|
29
|
+
* ブラウザ上でカラーの確認と調整を可能に
|
|
30
|
+
|
|
21
31
|
### v0.0.18 → → v0.0.22
|
|
22
32
|
|
|
23
33
|
* **[可視化モジュール]** ```--vis-voronoi```追加
|
|
@@ -58,17 +68,30 @@ $ npx -y -p splatone@latest crawler --help
|
|
|
58
68
|
使い方: crawler.js [options]
|
|
59
69
|
|
|
60
70
|
Basic Options
|
|
61
|
-
-p, --plugin
|
|
62
|
-
-k, --keywords
|
|
71
|
+
-p, --plugin 実行するプラグイン [文字列] [選択してください: "flickr"]
|
|
72
|
+
-k, --keywords 検索キーワード(|区切り) [文字列] [デフォルト:
|
|
63
73
|
"nature,tree,flower|building,house|water,sea,river,pond"]
|
|
64
|
-
-f, --filed
|
|
74
|
+
-f, --filed 大きなデータをファイルとして送受信する
|
|
65
75
|
[真偽] [デフォルト: true]
|
|
66
|
-
-c, --chopped
|
|
76
|
+
-c, --chopped 大きなデータを細分化して送受信する
|
|
67
77
|
[非推奨] [真偽] [デフォルト: false]
|
|
78
|
+
--browse-mode ブラウズ専用モード(範囲描画とクロールを無効化)
|
|
79
|
+
[真偽] [デフォルト: false]
|
|
68
80
|
|
|
69
81
|
Debug
|
|
70
82
|
--debug-verbose デバッグ情報出力 [真偽] [デフォルト: false]
|
|
71
83
|
|
|
84
|
+
UI Defaults
|
|
85
|
+
--ui-cell-size 起動時にUIへ設定するセルサイズ (0で自動)
|
|
86
|
+
[数値] [デフォルト: 0]
|
|
87
|
+
--ui-units セルサイズの単位 (kilometers/meters/miles)
|
|
88
|
+
[文字列] [選択してください: "kilometers", "meters", "miles"] [デフォルト:
|
|
89
|
+
"kilometers"]
|
|
90
|
+
--ui-bbox UI初期表示の矩形範囲。"minLon,minLat,maxLon,maxLat" の形式
|
|
91
|
+
[文字列]
|
|
92
|
+
--ui-polygon UI初期表示のポリゴン。Polygon/MultiPolygonを含むGeoJSON文
|
|
93
|
+
字列 [文字列]
|
|
94
|
+
|
|
72
95
|
For flickr Plugin
|
|
73
96
|
--p-flickr-APIKEY Flickr ServiceのAPI KEY [文字列]
|
|
74
97
|
--p-flickr-Extras カンマ区切り/保持する写真のメタデータ(デフォルト値は
|
|
@@ -78,7 +101,7 @@ For flickr Plugin
|
|
|
78
101
|
[選択してください: "upload", "taken"] [デフォルト: "upload"]
|
|
79
102
|
--p-flickr-Haste 時間軸分割並列処理 [真偽] [デフォルト: true]
|
|
80
103
|
--p-flickr-DateMax クローリング期間(最大) UNIX TIMEもしくはYYYY-MM-DD
|
|
81
|
-
[文字列] [デフォルト:
|
|
104
|
+
[文字列] [デフォルト: 1763465845]
|
|
82
105
|
--p-flickr-DateMin クローリング期間(最小) UNIX TIMEもしくはYYYY-MM-DD
|
|
83
106
|
[文字列] [デフォルト: 1072882800]
|
|
84
107
|
|
|
@@ -146,7 +169,7 @@ For voronoi Visualizer
|
|
|
146
169
|
|
|
147
170
|
オプション:
|
|
148
171
|
--help ヘルプを表示 [真偽]
|
|
149
|
-
--version バージョンを表示 [真偽]
|
|
172
|
+
--version バージョンを表示 [真偽]
|
|
150
173
|
```
|
|
151
174
|
|
|
152
175
|
## 最小コマンド例
|
|
@@ -165,6 +188,26 @@ For voronoi Visualizer
|
|
|
165
188
|
$ npx -y -p splatone@latest crawler -p flickr -k "canal,river|street,alley|bridge" --vis-bulky --p-flickr-APIKEY="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
|
166
189
|
```
|
|
167
190
|
|
|
191
|
+
## ブラウズ専用モード
|
|
192
|
+
|
|
193
|
+
ダウンロードした結果ファイルをブラウザ上で閲覧するためのモードです。
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
npx -y -p splatone@latest browse
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
あるいは
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
npx -y -p splatone@latest crawl --browse-mode
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
- ブラウザ上に result*.json(`crawler` が保存したファイル)をドラッグ&ドロップすると、その場で結果が地図へ描画されます。ズームやパン等Leafletの機能が使えます。
|
|
206
|
+
- CLI コマンド生成欄には、この結果を生成したコマンドが表示さるため、同じ条件をベースに新たなクエリを発行できます。
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
|
|
168
211
|
# 詳細説明
|
|
169
212
|
|
|
170
213
|
## Plugin (クローラー)
|
|
@@ -341,6 +384,8 @@ MinSiteSpacingMetersによる間引きは、各サイト周辺 (MinSiteSpacingMe
|
|
|
341
384
|
|
|
342
385
|
## キーワード指定方法
|
|
343
386
|
|
|
387
|
+
キーワードとはソーシャルデータを検索する単語の事で、複数のキーワードをしていする事で、地理的な出現頻度・分散を比較できます。
|
|
388
|
+
|
|
344
389
|
### 比較キーワードの指定
|
|
345
390
|
|
|
346
391
|
複数のキーワードでジオタグ付きポストを集め分布を比較します。比較キーワードは「|」区切りで指定します。例えばseaとmountainの分布を調べたい場合は以下のようにします。この例では、seaとタグ付けられたポストとmountainとタグ付けられたポストが色分けされて分布を表示します。
|
|
@@ -391,14 +436,21 @@ npx -y -p splatone@latest color <count> <sets>
|
|
|
391
436
|
npx -y -p splatone@latest color 6 3
|
|
392
437
|
```
|
|
393
438
|
|
|
394
|
-
-
|
|
439
|
+
- ブラウザでプレビューするか聞かれるのでYとすると、ブラウザ上で実際の色が確認できます。
|
|
440
|
+
- カラーピッカーになっていますので、微調整も可能です。
|
|
441
|
+
- カラーコードをクリックするとコピーされます。
|
|
395
442
|
|
|
396
|
-
|
|
443
|
+

|
|
444
|
+
|
|
445
|
+
- オプション:
|
|
446
|
+
- `--no-ansi` : ANSI カラーシーケンスを出力せず、プレーンなカンマ区切りの HEX を出力します(パイプやログ向け)。
|
|
397
447
|
|
|
398
448
|
```bash
|
|
399
449
|
npx -y -p splatone@latest color --no-ansi 6 3
|
|
400
450
|
```
|
|
401
451
|
|
|
452
|
+
|
|
453
|
+
|
|
402
454
|
## ダウンロード
|
|
403
455
|
|
|
404
456
|
### 画像のダウンロード
|
|
@@ -413,9 +465,6 @@ npx -y -p splatone@latest color --no-ansi 6 3
|
|
|
413
465
|
* クロール結果をデータとしてダウンロードしたい場合は凡例の下にあるエクスポートボタンをクリックしてください。
|
|
414
466
|
* 指定したビジュアライザ毎にFeature Collectionとして結果が格納されます。
|
|
415
467
|
* クローリングしたデータそのものが欲しい場合はBulky等、単純なビジュアライザを指定してください。
|
|
416
|
-
|
|
417
|
-

|
|
418
|
-
|
|
419
468
|
### 広範囲なデータ収集例
|
|
420
469
|
|
|
421
470
|
* クエリ数はおおよそ1 query/secに調整されますので、時間はかかりますが大量のデータを収集する事も可能です。
|
package/browse.js
ADDED
package/color.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import readline from 'node:readline';
|
|
3
7
|
import paletteGenerator from './lib/paletteGenerator.js';
|
|
8
|
+
import open from 'open';
|
|
9
|
+
import chroma from 'chroma-js';
|
|
4
10
|
|
|
5
11
|
// Usage: node color.js <count> <sets>
|
|
6
12
|
// Example: node color.js 6 3
|
|
@@ -26,6 +32,8 @@ async function main() {
|
|
|
26
32
|
|
|
27
33
|
const count = parseInt(argv[0], 10) || 8;
|
|
28
34
|
const sets = parseInt(argv[1], 10) || 1;
|
|
35
|
+
const generatedPalettes = [];
|
|
36
|
+
const poolSize = Math.max((count || 1) * 3, (count || 1) + 6);
|
|
29
37
|
|
|
30
38
|
for (let s = 0; s < sets; s++) {
|
|
31
39
|
// Try to generate a palette via paletteGenerator; if it fails, fallback to HSL per-set
|
|
@@ -35,7 +43,7 @@ async function main() {
|
|
|
35
43
|
Math.random.seed(Date.now() + s);
|
|
36
44
|
}
|
|
37
45
|
cols = paletteGenerator.generate(
|
|
38
|
-
|
|
46
|
+
poolSize,
|
|
39
47
|
function (color) {
|
|
40
48
|
var hcl = color.hcl();
|
|
41
49
|
return hcl[0] >= 0 && hcl[0] <= 360
|
|
@@ -63,12 +71,16 @@ async function main() {
|
|
|
63
71
|
return `#${f(0)}${f(8)}${f(4)}`;
|
|
64
72
|
}
|
|
65
73
|
|
|
66
|
-
const
|
|
67
|
-
Array.from({ length:
|
|
68
|
-
const hue = Math.round(((i /
|
|
74
|
+
const rawHexes = (cols ? cols.map(c => (typeof c.hex === 'function' ? c.hex() : String(c))) :
|
|
75
|
+
Array.from({ length: poolSize }, (_, i) => {
|
|
76
|
+
const hue = Math.round(((i / poolSize) * 360 + s * 37) % 360);
|
|
69
77
|
return hslToHex(hue, 0.65, 0.5);
|
|
70
78
|
})
|
|
71
79
|
);
|
|
80
|
+
const uniqueHexes = [...new Map(rawHexes.map(h => [String(h).toUpperCase(), normalizeHex(h)])).values()];
|
|
81
|
+
const hexes = selectDiverseColors(uniqueHexes, count);
|
|
82
|
+
|
|
83
|
+
generatedPalettes.push(hexes);
|
|
72
84
|
|
|
73
85
|
if (noAnsi) {
|
|
74
86
|
console.log(hexes.join(','));
|
|
@@ -87,6 +99,8 @@ async function main() {
|
|
|
87
99
|
console.log(out);
|
|
88
100
|
}
|
|
89
101
|
}
|
|
102
|
+
|
|
103
|
+
await maybeLaunchPreview(generatedPalettes);
|
|
90
104
|
}
|
|
91
105
|
|
|
92
106
|
main().catch(err => {
|
|
@@ -94,4 +108,346 @@ main().catch(err => {
|
|
|
94
108
|
process.exit(1);
|
|
95
109
|
});
|
|
96
110
|
|
|
111
|
+
function isInteractive() {
|
|
112
|
+
return Boolean(process.stdout.isTTY && process.stdin.isTTY);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function maybeLaunchPreview(palettes) {
|
|
116
|
+
if (!isInteractive() || palettes.length === 0) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const answer = await promptYesNo('Open browser preview? [y/N]: ');
|
|
120
|
+
if (!/^y(es)?$/i.test(answer.trim())) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
await launchPreview(palettes);
|
|
125
|
+
} catch (err) {
|
|
126
|
+
console.warn('[color.js] Failed to open preview:', err?.message || err);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function promptYesNo(question) {
|
|
131
|
+
return new Promise((resolve) => {
|
|
132
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
133
|
+
rl.question(question, (answer) => {
|
|
134
|
+
rl.close();
|
|
135
|
+
resolve(answer || '');
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function launchPreview(palettes) {
|
|
141
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'splatone-colors-'));
|
|
142
|
+
const filePath = path.join(dir, 'index.html');
|
|
143
|
+
const html = buildPreviewHtml(palettes);
|
|
144
|
+
await fs.writeFile(filePath, html, 'utf8');
|
|
145
|
+
await open(filePath, { wait: false });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function buildPreviewHtml(palettes) {
|
|
149
|
+
const paletteData = JSON.stringify(palettes);
|
|
150
|
+
return `<!doctype html>
|
|
151
|
+
<html lang="en">
|
|
152
|
+
<head>
|
|
153
|
+
<meta charset="utf-8" />
|
|
154
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
155
|
+
<title>Splatone Color Preview</title>
|
|
156
|
+
<style>
|
|
157
|
+
:root {
|
|
158
|
+
font-family: "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
|
159
|
+
color-scheme: light dark;
|
|
160
|
+
}
|
|
161
|
+
body {
|
|
162
|
+
margin: 0;
|
|
163
|
+
padding: 0;
|
|
164
|
+
background: #0f0f0f;
|
|
165
|
+
color: #f5f5f5;
|
|
166
|
+
min-height: 100vh;
|
|
167
|
+
display: flex;
|
|
168
|
+
justify-content: center;
|
|
169
|
+
align-items: flex-start;
|
|
170
|
+
font-size: 16px;
|
|
171
|
+
}
|
|
172
|
+
main {
|
|
173
|
+
width: min(960px, 100%);
|
|
174
|
+
padding: 32px 24px 48px;
|
|
175
|
+
}
|
|
176
|
+
h1 {
|
|
177
|
+
margin-top: 0;
|
|
178
|
+
font-size: clamp(1.5rem, 2vw, 2rem);
|
|
179
|
+
}
|
|
180
|
+
.palette {
|
|
181
|
+
background: rgba(255, 255, 255, 0.02);
|
|
182
|
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
183
|
+
border-radius: 12px;
|
|
184
|
+
padding: 16px;
|
|
185
|
+
margin-bottom: 24px;
|
|
186
|
+
}
|
|
187
|
+
.palette h2 {
|
|
188
|
+
margin: 0 0 12px;
|
|
189
|
+
font-size: 1rem;
|
|
190
|
+
font-weight: 600;
|
|
191
|
+
letter-spacing: 0.08em;
|
|
192
|
+
text-transform: uppercase;
|
|
193
|
+
color: #9acdff;
|
|
194
|
+
}
|
|
195
|
+
.color-grid {
|
|
196
|
+
display: grid;
|
|
197
|
+
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
198
|
+
gap: 16px;
|
|
199
|
+
}
|
|
200
|
+
.color-card {
|
|
201
|
+
background: rgba(0, 0, 0, 0.3);
|
|
202
|
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
203
|
+
border-radius: 10px;
|
|
204
|
+
padding: 12px 12px 16px;
|
|
205
|
+
display: flex;
|
|
206
|
+
flex-direction: column;
|
|
207
|
+
gap: 8px;
|
|
208
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
|
209
|
+
}
|
|
210
|
+
.swatch {
|
|
211
|
+
width: 100%;
|
|
212
|
+
aspect-ratio: 5 / 3;
|
|
213
|
+
border-radius: 8px;
|
|
214
|
+
border: 1px solid rgba(255, 255, 255, 0.15);
|
|
215
|
+
transition: transform 0.15s ease;
|
|
216
|
+
}
|
|
217
|
+
.swatch:hover {
|
|
218
|
+
transform: scale(1.02);
|
|
219
|
+
}
|
|
220
|
+
.color-picker {
|
|
221
|
+
width: 100%;
|
|
222
|
+
height: 40px;
|
|
223
|
+
border: none;
|
|
224
|
+
border-radius: 6px;
|
|
225
|
+
padding: 0;
|
|
226
|
+
background: transparent;
|
|
227
|
+
cursor: pointer;
|
|
228
|
+
}
|
|
229
|
+
.hex-code {
|
|
230
|
+
display: inline-flex;
|
|
231
|
+
justify-content: center;
|
|
232
|
+
align-items: center;
|
|
233
|
+
padding: 10px;
|
|
234
|
+
border-radius: 6px;
|
|
235
|
+
border: 1px solid rgba(255, 255, 255, 0.15);
|
|
236
|
+
background: rgba(255, 255, 255, 0.05);
|
|
237
|
+
color: inherit;
|
|
238
|
+
font-family: "JetBrains Mono", "Cascadia Code", Consolas, monospace;
|
|
239
|
+
font-size: 0.95rem;
|
|
240
|
+
letter-spacing: 0.08em;
|
|
241
|
+
cursor: pointer;
|
|
242
|
+
transition: background 0.15s ease, border-color 0.15s ease;
|
|
243
|
+
}
|
|
244
|
+
.hex-code:hover {
|
|
245
|
+
background: rgba(255, 255, 255, 0.12);
|
|
246
|
+
border-color: rgba(255, 255, 255, 0.4);
|
|
247
|
+
}
|
|
248
|
+
.toast {
|
|
249
|
+
position: fixed;
|
|
250
|
+
bottom: 24px;
|
|
251
|
+
right: 24px;
|
|
252
|
+
padding: 12px 20px;
|
|
253
|
+
border-radius: 999px;
|
|
254
|
+
background: rgba(0, 0, 0, 0.8);
|
|
255
|
+
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
256
|
+
opacity: 0;
|
|
257
|
+
transform: translateY(12px);
|
|
258
|
+
transition: opacity 0.2s ease, transform 0.2s ease;
|
|
259
|
+
pointer-events: none;
|
|
260
|
+
font-size: 0.95rem;
|
|
261
|
+
}
|
|
262
|
+
.toast.visible {
|
|
263
|
+
opacity: 1;
|
|
264
|
+
transform: translateY(0);
|
|
265
|
+
}
|
|
266
|
+
@media (prefers-color-scheme: light) {
|
|
267
|
+
body {
|
|
268
|
+
background: #fafafa;
|
|
269
|
+
color: #111;
|
|
270
|
+
}
|
|
271
|
+
.palette {
|
|
272
|
+
background: #fff;
|
|
273
|
+
border-color: rgba(15, 23, 42, 0.08);
|
|
274
|
+
}
|
|
275
|
+
.color-card {
|
|
276
|
+
background: #f8fafc;
|
|
277
|
+
border-color: rgba(15, 23, 42, 0.08);
|
|
278
|
+
}
|
|
279
|
+
.hex-code {
|
|
280
|
+
background: rgba(15, 23, 42, 0.04);
|
|
281
|
+
border-color: rgba(15, 23, 42, 0.1);
|
|
282
|
+
}
|
|
283
|
+
.hex-code:hover {
|
|
284
|
+
background: rgba(15, 23, 42, 0.08);
|
|
285
|
+
}
|
|
286
|
+
.toast {
|
|
287
|
+
background: rgba(15, 23, 42, 0.9);
|
|
288
|
+
color: #fff;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
</style>
|
|
292
|
+
</head>
|
|
293
|
+
<body>
|
|
294
|
+
<main>
|
|
295
|
+
<h1>Splatone Palette Preview</h1>
|
|
296
|
+
<p>Use the color pickers to fine-tune each swatch. Click any hex code to copy it to your clipboard.</p>
|
|
297
|
+
<div id="palettes"></div>
|
|
298
|
+
</main>
|
|
299
|
+
<div class="toast" id="copy-toast">Copied</div>
|
|
300
|
+
<script>
|
|
301
|
+
const palettes = ${paletteData};
|
|
302
|
+
const container = document.getElementById('palettes');
|
|
303
|
+
|
|
304
|
+
function createPaletteSection(colors, index) {
|
|
305
|
+
const section = document.createElement('section');
|
|
306
|
+
section.className = 'palette';
|
|
307
|
+
const heading = document.createElement('h2');
|
|
308
|
+
heading.textContent = 'Set ' + (index + 1);
|
|
309
|
+
section.appendChild(heading);
|
|
310
|
+
const grid = document.createElement('div');
|
|
311
|
+
grid.className = 'color-grid';
|
|
312
|
+
colors.forEach((hex, colorIdx) => {
|
|
313
|
+
grid.appendChild(createColorCard(hex, index, colorIdx));
|
|
314
|
+
});
|
|
315
|
+
section.appendChild(grid);
|
|
316
|
+
return section;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function createColorCard(hex, setIdx, colorIdx) {
|
|
320
|
+
const card = document.createElement('article');
|
|
321
|
+
card.className = 'color-card';
|
|
322
|
+
const swatch = document.createElement('div');
|
|
323
|
+
swatch.className = 'swatch';
|
|
324
|
+
swatch.style.background = hex;
|
|
325
|
+
swatch.dataset.hex = hex;
|
|
326
|
+
|
|
327
|
+
const picker = document.createElement('input');
|
|
328
|
+
picker.type = 'color';
|
|
329
|
+
picker.className = 'color-picker';
|
|
330
|
+
picker.value = hex;
|
|
331
|
+
picker.setAttribute('aria-label', 'Adjust color ' + (colorIdx + 1) + ' in set ' + (setIdx + 1));
|
|
332
|
+
|
|
333
|
+
const hexButton = document.createElement('button');
|
|
334
|
+
hexButton.type = 'button';
|
|
335
|
+
hexButton.className = 'hex-code';
|
|
336
|
+
hexButton.dataset.hex = hex.toUpperCase();
|
|
337
|
+
hexButton.textContent = hex.toUpperCase();
|
|
338
|
+
|
|
339
|
+
picker.addEventListener('input', () => {
|
|
340
|
+
const value = picker.value.toUpperCase();
|
|
341
|
+
swatch.style.background = value;
|
|
342
|
+
hexButton.dataset.hex = value;
|
|
343
|
+
hexButton.textContent = value;
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
hexButton.addEventListener('click', () => copyHex(hexButton.dataset.hex));
|
|
347
|
+
|
|
348
|
+
card.appendChild(swatch);
|
|
349
|
+
card.appendChild(picker);
|
|
350
|
+
card.appendChild(hexButton);
|
|
351
|
+
return card;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function renderPalettes() {
|
|
355
|
+
container.innerHTML = '';
|
|
356
|
+
palettes.forEach((colors, idx) => {
|
|
357
|
+
container.appendChild(createPaletteSection(colors, idx));
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function copyHex(hex) {
|
|
362
|
+
try {
|
|
363
|
+
await navigator.clipboard.writeText(hex);
|
|
364
|
+
showToast(hex + ' copied');
|
|
365
|
+
} catch (err) {
|
|
366
|
+
showToast('Clipboard unavailable');
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
let toastTimer = null;
|
|
371
|
+
function showToast(message) {
|
|
372
|
+
const toast = document.getElementById('copy-toast');
|
|
373
|
+
toast.textContent = message;
|
|
374
|
+
toast.classList.add('visible');
|
|
375
|
+
clearTimeout(toastTimer);
|
|
376
|
+
toastTimer = setTimeout(() => toast.classList.remove('visible'), 1600);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
renderPalettes();
|
|
380
|
+
</script>
|
|
381
|
+
</body>
|
|
382
|
+
</html>`;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function normalizeHex(value) {
|
|
386
|
+
const hex = String(value || '').trim();
|
|
387
|
+
if (!hex) return '#000000';
|
|
388
|
+
if (hex.startsWith('#')) {
|
|
389
|
+
if (hex.length === 4) {
|
|
390
|
+
return '#' + hex.slice(1).split('').map(ch => ch + ch).join('').toUpperCase();
|
|
391
|
+
}
|
|
392
|
+
return '#' + hex.slice(1, 7).padEnd(6, '0').toUpperCase();
|
|
393
|
+
}
|
|
394
|
+
if (/^[0-9a-f]{6}$/i.test(hex)) {
|
|
395
|
+
return '#' + hex.toUpperCase();
|
|
396
|
+
}
|
|
397
|
+
return '#000000';
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function labDistance(hexA, hexB) {
|
|
401
|
+
try {
|
|
402
|
+
const [L1, a1, b1] = chroma(hexA).lab();
|
|
403
|
+
const [L2, a2, b2] = chroma(hexB).lab();
|
|
404
|
+
const dL = L1 - L2;
|
|
405
|
+
const da = a1 - a2;
|
|
406
|
+
const db = b1 - b2;
|
|
407
|
+
return Math.sqrt(dL * dL + da * da + db * db);
|
|
408
|
+
} catch {
|
|
409
|
+
return 0;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function selectDiverseColors(hexes, count) {
|
|
414
|
+
if (!Array.isArray(hexes) || hexes.length === 0) {
|
|
415
|
+
return [];
|
|
416
|
+
}
|
|
417
|
+
if (hexes.length <= count) {
|
|
418
|
+
return hexes.slice(0, count);
|
|
419
|
+
}
|
|
420
|
+
const pool = hexes.slice();
|
|
421
|
+
const selected = [];
|
|
422
|
+
let firstIdx = 0;
|
|
423
|
+
let bestVariance = -Infinity;
|
|
424
|
+
for (let i = 0; i < pool.length; i++) {
|
|
425
|
+
const [L, a, b] = chroma(pool[i]).lab();
|
|
426
|
+
const variance = (L - 50) * (L - 50) + a * a + b * b;
|
|
427
|
+
if (variance > bestVariance) {
|
|
428
|
+
bestVariance = variance;
|
|
429
|
+
firstIdx = i;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
selected.push(pool.splice(firstIdx, 1)[0]);
|
|
433
|
+
while (selected.length < count && pool.length) {
|
|
434
|
+
let bestIdx = 0;
|
|
435
|
+
let bestScore = -1;
|
|
436
|
+
for (let i = 0; i < pool.length; i++) {
|
|
437
|
+
const candidate = pool[i];
|
|
438
|
+
let minDistance = Infinity;
|
|
439
|
+
for (const chosen of selected) {
|
|
440
|
+
minDistance = Math.min(minDistance, labDistance(candidate, chosen));
|
|
441
|
+
if (minDistance === 0) break;
|
|
442
|
+
}
|
|
443
|
+
if (minDistance > bestScore) {
|
|
444
|
+
bestScore = minDistance;
|
|
445
|
+
bestIdx = i;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
selected.push(pool.splice(bestIdx, 1)[0]);
|
|
449
|
+
}
|
|
450
|
+
return selected;
|
|
451
|
+
}
|
|
452
|
+
|
|
97
453
|
|