python-lucide 0.2.28__tar.gz → 0.4.0__tar.gz
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.
- {python_lucide-0.2.28 → python_lucide-0.4.0}/PKG-INFO +3 -1
- {python_lucide-0.2.28 → python_lucide-0.4.0}/pyproject.toml +26 -5
- {python_lucide-0.2.28 → python_lucide-0.4.0}/src/lucide/__init__.py +12 -0
- python_lucide-0.4.0/src/lucide/build_clusters.py +324 -0
- python_lucide-0.4.0/src/lucide/build_search.py +794 -0
- python_lucide-0.4.0/src/lucide/cli.py +945 -0
- python_lucide-0.4.0/src/lucide/config.py +101 -0
- python_lucide-0.4.0/src/lucide/data/gemini-icon-descriptions.jsonl +1733 -0
- python_lucide-0.4.0/src/lucide/data/lucide-icon-clusters.json +9028 -0
- python_lucide-0.4.0/src/lucide/data/lucide-icons.db +0 -0
- python_lucide-0.4.0/src/lucide/search.py +380 -0
- python_lucide-0.4.0/tests/build_clusters_test.py +107 -0
- python_lucide-0.4.0/tests/build_search_test.py +364 -0
- python_lucide-0.4.0/tests/cli_test.py +225 -0
- python_lucide-0.4.0/tests/conftest.py +24 -0
- python_lucide-0.4.0/tests/search_test.py +377 -0
- python_lucide-0.2.28/src/lucide/cli.py +0 -418
- python_lucide-0.2.28/src/lucide/config.py +0 -10
- python_lucide-0.2.28/src/lucide/data/lucide-icons.db +0 -0
- python_lucide-0.2.28/tests/cli_test.py +0 -153
- {python_lucide-0.2.28 → python_lucide-0.4.0}/README.md +0 -0
- {python_lucide-0.2.28 → python_lucide-0.4.0}/src/lucide/core.py +0 -0
- {python_lucide-0.2.28 → python_lucide-0.4.0}/src/lucide/data/__init__.py +0 -0
- {python_lucide-0.2.28 → python_lucide-0.4.0}/src/lucide/db.py +0 -0
- {python_lucide-0.2.28 → python_lucide-0.4.0}/src/lucide/dev_utils.py +0 -0
- {python_lucide-0.2.28 → python_lucide-0.4.0}/tests/__init__.py +0 -0
- {python_lucide-0.2.28 → python_lucide-0.4.0}/tests/core_test.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: python-lucide
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: A Python package for working with Lucide icons
|
|
5
5
|
Keywords: lucide,icons,svg,sqlite
|
|
6
6
|
Author: Mike Macpherson
|
|
@@ -15,10 +15,12 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.13
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.14
|
|
17
17
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Requires-Dist: fastembed>=0.4.0 ; extra == 'search'
|
|
18
19
|
Requires-Python: >=3.10
|
|
19
20
|
Project-URL: Bug Tracker, https://github.com/mmacpherson/python-lucide/issues
|
|
20
21
|
Project-URL: Homepage, https://github.com/mmacpherson/python-lucide
|
|
21
22
|
Project-URL: Source Code, https://github.com/mmacpherson/python-lucide
|
|
23
|
+
Provides-Extra: search
|
|
22
24
|
Description-Content-Type: text/markdown
|
|
23
25
|
|
|
24
26
|
# python-lucide
|
|
@@ -4,14 +4,20 @@ requires = ["uv_build>=0.8.3,<0.12.0"]
|
|
|
4
4
|
|
|
5
5
|
[dependency-groups]
|
|
6
6
|
dev = [
|
|
7
|
+
"cairosvg>=2.7.0",
|
|
8
|
+
"fastembed>=0.4.0",
|
|
9
|
+
"hdbscan>=0.8.0",
|
|
7
10
|
"mypy-extensions>=1.0.0",
|
|
8
11
|
"mypy>=1.0.0",
|
|
12
|
+
"plotly>=5.0.0",
|
|
9
13
|
"pre-commit>=3.0.0",
|
|
14
|
+
"pydantic-ai-slim[google]>=1.0.0",
|
|
10
15
|
"pytest-cov>=4.0.0",
|
|
11
16
|
"pytest>=7.0.0",
|
|
12
17
|
"ruff>=0.1.0",
|
|
13
18
|
"types-setuptools>=80.9.0.20250529",
|
|
14
|
-
"typing-extensions>=4.1.0"
|
|
19
|
+
"typing-extensions>=4.1.0",
|
|
20
|
+
"umap-learn>=0.5.0"
|
|
15
21
|
]
|
|
16
22
|
|
|
17
23
|
[project]
|
|
@@ -35,11 +41,15 @@ license = {text = "MIT"}
|
|
|
35
41
|
name = "python-lucide"
|
|
36
42
|
readme = "README.md"
|
|
37
43
|
requires-python = ">=3.10"
|
|
38
|
-
version = "0.
|
|
44
|
+
version = "0.4.0"
|
|
45
|
+
|
|
46
|
+
[project.optional-dependencies]
|
|
47
|
+
search = ["fastembed>=0.4.0"]
|
|
39
48
|
|
|
40
49
|
[project.scripts]
|
|
41
50
|
check-lucide-version = "lucide.dev_utils:print_version_status"
|
|
42
|
-
lucide
|
|
51
|
+
lucide = "lucide.cli:main"
|
|
52
|
+
lucide-db = "lucide.cli:main_legacy_db"
|
|
43
53
|
|
|
44
54
|
[project.urls]
|
|
45
55
|
"Bug Tracker" = "https://github.com/mmacpherson/python-lucide/issues"
|
|
@@ -126,7 +136,8 @@ section-order = [
|
|
|
126
136
|
|
|
127
137
|
[tool.ruff.lint.per-file-ignores]
|
|
128
138
|
"**/__init__.py" = ["F401"]
|
|
129
|
-
"
|
|
139
|
+
"scripts/**/*.py" = ["D", "SLF001", "PLR2004", "PLR0915", "PLC0415", "ERA001", "E741", "C408"]
|
|
140
|
+
"tests/**/*.py" = ["D", "SLF001", "PLR2004", "ARG005"]
|
|
130
141
|
|
|
131
142
|
[tool.ruff.lint.pydocstyle]
|
|
132
143
|
convention = "google"
|
|
@@ -134,8 +145,18 @@ convention = "google"
|
|
|
134
145
|
[tool.uv.build-backend]
|
|
135
146
|
# The module name differs from the normalized package name
|
|
136
147
|
module-name = "lucide"
|
|
148
|
+
# The search DB is a derived artifact published as a GitHub release asset
|
|
149
|
+
# and downloaded on first use; never package it
|
|
150
|
+
source-exclude = ["src/lucide/data/lucide-search.db"]
|
|
137
151
|
# Include tests and database files in source distribution
|
|
138
152
|
source-include = [
|
|
139
153
|
"tests/**",
|
|
140
|
-
"src/lucide/data
|
|
154
|
+
"src/lucide/data/lucide-icons.db"
|
|
155
|
+
]
|
|
156
|
+
# Build-time data (descriptions, clusters) stays in the sdist/repo but out
|
|
157
|
+
# of the runtime wheel
|
|
158
|
+
wheel-exclude = [
|
|
159
|
+
"lucide/data/lucide-search.db",
|
|
160
|
+
"lucide/data/gemini-icon-descriptions.jsonl",
|
|
161
|
+
"lucide/data/lucide-icon-clusters.json"
|
|
141
162
|
]
|
|
@@ -8,11 +8,23 @@ __version__ = "0.1.0"
|
|
|
8
8
|
|
|
9
9
|
from .core import create_placeholder_svg, get_icon_list, lucide_icon
|
|
10
10
|
from .db import get_db_connection, get_default_db_path
|
|
11
|
+
from .search import (
|
|
12
|
+
SearchNotAvailableError,
|
|
13
|
+
SearchResult,
|
|
14
|
+
get_icon_description,
|
|
15
|
+
search_available,
|
|
16
|
+
search_icons,
|
|
17
|
+
)
|
|
11
18
|
|
|
12
19
|
__all__ = [
|
|
20
|
+
"SearchNotAvailableError",
|
|
21
|
+
"SearchResult",
|
|
13
22
|
"create_placeholder_svg",
|
|
14
23
|
"get_db_connection",
|
|
15
24
|
"get_default_db_path",
|
|
25
|
+
"get_icon_description",
|
|
16
26
|
"get_icon_list",
|
|
17
27
|
"lucide_icon",
|
|
28
|
+
"search_available",
|
|
29
|
+
"search_icons",
|
|
18
30
|
]
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""Discover semantic clusters in the icon embedding space and name them.
|
|
2
|
+
|
|
3
|
+
Uses UMAP for dimensionality reduction, HDBSCAN for density-based
|
|
4
|
+
clustering, and Gemini Flash to generate evocative theme names from
|
|
5
|
+
the icon names in each cluster.
|
|
6
|
+
|
|
7
|
+
Build-time only — requires ``umap-learn``, ``hdbscan``, and ``plotly``
|
|
8
|
+
(all in the dev dependency group).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import pathlib
|
|
15
|
+
import sqlite3
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
|
|
18
|
+
from pydantic import BaseModel, field_validator
|
|
19
|
+
from pydantic_ai import Agent
|
|
20
|
+
from pydantic_ai.models import Model
|
|
21
|
+
|
|
22
|
+
from .config import DEFAULT_SEARCH_MODEL_ID
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
CLUSTER_NAMING_MODEL = "gemini-2.5-flash"
|
|
27
|
+
|
|
28
|
+
NAMING_INSTRUCTIONS = """\
|
|
29
|
+
You name clusters of icons. Given icon names that form a visual/semantic
|
|
30
|
+
cluster, respond with a short, evocative theme name (2-4 words).
|
|
31
|
+
Be specific about what unifies the icons.
|
|
32
|
+
Do not use generic labels like "UI elements" or "miscellaneous"."""
|
|
33
|
+
|
|
34
|
+
# Generous bound for a "2-4 word" name; anything longer means the model
|
|
35
|
+
# ignored the instruction (e.g. leaked its reasoning)
|
|
36
|
+
MAX_THEME_LENGTH = 40
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _sanitize_theme(raw: str) -> str | None:
|
|
40
|
+
"""Validate a model-proposed theme name.
|
|
41
|
+
|
|
42
|
+
Models occasionally return their full chain-of-thought instead of just
|
|
43
|
+
the name — two shipped clusters once carried ~4k-char reasoning dumps
|
|
44
|
+
as their theme. Reject anything multi-line or implausibly long rather
|
|
45
|
+
than trying to salvage it.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
raw: The raw model response text.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
The cleaned theme name, or None if the response is unusable.
|
|
52
|
+
"""
|
|
53
|
+
theme = raw.strip().strip("\"'").strip()
|
|
54
|
+
if not theme or "\n" in theme or len(theme) > MAX_THEME_LENGTH:
|
|
55
|
+
return None
|
|
56
|
+
return theme
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ClusterTheme(BaseModel):
|
|
60
|
+
"""Structured output for cluster naming.
|
|
61
|
+
|
|
62
|
+
A failed validation here becomes a retry request to the model, so a
|
|
63
|
+
leaked chain-of-thought gets re-asked instead of stored or discarded.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
theme: str
|
|
67
|
+
|
|
68
|
+
@field_validator("theme")
|
|
69
|
+
@classmethod
|
|
70
|
+
def _must_be_short_single_line(cls, value: str) -> str:
|
|
71
|
+
clean = _sanitize_theme(value)
|
|
72
|
+
if clean is None:
|
|
73
|
+
raise ValueError(
|
|
74
|
+
f"theme must be a single line of at most {MAX_THEME_LENGTH} "
|
|
75
|
+
"characters — return only the 2-4 word name itself"
|
|
76
|
+
)
|
|
77
|
+
return clean
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def discover_clusters(
|
|
81
|
+
search_db_path: pathlib.Path,
|
|
82
|
+
*,
|
|
83
|
+
min_cluster_size: int = 5,
|
|
84
|
+
) -> dict:
|
|
85
|
+
"""Run UMAP + HDBSCAN on the icon embeddings.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
search_db_path: Path to the search SQLite database.
|
|
89
|
+
min_cluster_size: Minimum icons to form a cluster.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Dict with keys: clusters, coords, descriptions.
|
|
93
|
+
"""
|
|
94
|
+
import numpy as np # noqa: PLC0415
|
|
95
|
+
import umap # noqa: PLC0415
|
|
96
|
+
from hdbscan import HDBSCAN # noqa: PLC0415
|
|
97
|
+
|
|
98
|
+
conn = sqlite3.connect(f"file:{search_db_path}?mode=ro", uri=True)
|
|
99
|
+
rows = conn.execute(
|
|
100
|
+
"SELECT e.name, e.embedding, d.description "
|
|
101
|
+
"FROM icon_embeddings e "
|
|
102
|
+
"JOIN icon_descriptions d ON e.name = d.name "
|
|
103
|
+
"WHERE e.model = ? "
|
|
104
|
+
"ORDER BY e.name",
|
|
105
|
+
(DEFAULT_SEARCH_MODEL_ID,),
|
|
106
|
+
).fetchall()
|
|
107
|
+
conn.close()
|
|
108
|
+
|
|
109
|
+
names = [r[0] for r in rows]
|
|
110
|
+
descriptions = {r[0]: r[2] for r in rows}
|
|
111
|
+
matrix = np.stack([np.frombuffer(r[1], dtype=np.float32) for r in rows])
|
|
112
|
+
logger.info("Loaded %d embeddings (%dd)", len(names), matrix.shape[1])
|
|
113
|
+
|
|
114
|
+
logger.info("Running UMAP (2D projection)...")
|
|
115
|
+
reducer = umap.UMAP(
|
|
116
|
+
n_components=2,
|
|
117
|
+
n_neighbors=15,
|
|
118
|
+
min_dist=0.1,
|
|
119
|
+
metric="cosine",
|
|
120
|
+
random_state=42,
|
|
121
|
+
)
|
|
122
|
+
coords_array = reducer.fit_transform(matrix)
|
|
123
|
+
|
|
124
|
+
logger.info("Running HDBSCAN (min_cluster_size=%d)...", min_cluster_size)
|
|
125
|
+
clusterer = HDBSCAN(
|
|
126
|
+
min_cluster_size=min_cluster_size,
|
|
127
|
+
metric="euclidean",
|
|
128
|
+
cluster_selection_method="eom",
|
|
129
|
+
)
|
|
130
|
+
labels = clusterer.fit_predict(coords_array)
|
|
131
|
+
|
|
132
|
+
n_clusters = len(set(labels)) - (1 if -1 in labels else 0)
|
|
133
|
+
n_noise = int((labels == -1).sum())
|
|
134
|
+
logger.info("Found %d clusters, %d unclustered points", n_clusters, n_noise)
|
|
135
|
+
|
|
136
|
+
clusters: dict[str, dict] = {}
|
|
137
|
+
for i, label in enumerate(labels):
|
|
138
|
+
lid = str(int(label))
|
|
139
|
+
if lid not in clusters:
|
|
140
|
+
clusters[lid] = {"icons": [], "theme": None}
|
|
141
|
+
clusters[lid]["icons"].append(names[i])
|
|
142
|
+
|
|
143
|
+
for lid in clusters:
|
|
144
|
+
clusters[lid]["icons"].sort()
|
|
145
|
+
|
|
146
|
+
coords = {
|
|
147
|
+
names[i]: [float(coords_array[i, 0]), float(coords_array[i, 1])]
|
|
148
|
+
for i in range(len(names))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
"clusters": clusters,
|
|
153
|
+
"coords": coords,
|
|
154
|
+
"descriptions": descriptions,
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def name_clusters(
|
|
159
|
+
data: dict,
|
|
160
|
+
*,
|
|
161
|
+
api_key: str | None = None,
|
|
162
|
+
model: Model | None = None,
|
|
163
|
+
) -> dict:
|
|
164
|
+
"""Name each cluster using Gemini Flash via a Pydantic AI agent.
|
|
165
|
+
|
|
166
|
+
Output is validated by ``ClusterTheme``; an invalid response (e.g. a
|
|
167
|
+
leaked chain-of-thought) triggers an automatic retry instead of being
|
|
168
|
+
stored. Only after retries are exhausted does a cluster fall back to a
|
|
169
|
+
``Cluster {id}`` placeholder.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
data: Output from ``discover_clusters()``.
|
|
173
|
+
api_key: Gemini API key. Falls back to ``GEMINI_API_KEY`` env var.
|
|
174
|
+
model: Model override, used by tests to avoid real API calls.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
The same data dict with ``theme`` populated for each cluster.
|
|
178
|
+
"""
|
|
179
|
+
if model is None:
|
|
180
|
+
from pydantic_ai.models.google import GoogleModel # noqa: PLC0415
|
|
181
|
+
from pydantic_ai.providers.google import GoogleProvider # noqa: PLC0415
|
|
182
|
+
|
|
183
|
+
api_key = api_key or os.environ.get("GEMINI_API_KEY")
|
|
184
|
+
if not api_key:
|
|
185
|
+
raise ValueError("Gemini API key required. Set GEMINI_API_KEY.")
|
|
186
|
+
model = GoogleModel(
|
|
187
|
+
CLUSTER_NAMING_MODEL, provider=GoogleProvider(api_key=api_key)
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
agent = Agent(
|
|
191
|
+
model,
|
|
192
|
+
output_type=ClusterTheme,
|
|
193
|
+
instructions=NAMING_INSTRUCTIONS,
|
|
194
|
+
output_retries=3,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
clusters = data["clusters"]
|
|
198
|
+
for lid in sorted(clusters, key=lambda k: -len(clusters[k]["icons"])):
|
|
199
|
+
if lid == "-1":
|
|
200
|
+
clusters[lid]["theme"] = "Unclustered"
|
|
201
|
+
continue
|
|
202
|
+
|
|
203
|
+
icons = clusters[lid]["icons"]
|
|
204
|
+
# Cap at 40 names to keep prompt short
|
|
205
|
+
icon_names = ", ".join(icons[:40])
|
|
206
|
+
try:
|
|
207
|
+
result = agent.run_sync(f"Icon cluster: {icon_names}")
|
|
208
|
+
clusters[lid]["theme"] = result.output.theme
|
|
209
|
+
logger.info(
|
|
210
|
+
"Cluster %s (%d icons): %s", lid, len(icons), result.output.theme
|
|
211
|
+
)
|
|
212
|
+
except Exception:
|
|
213
|
+
clusters[lid]["theme"] = f"Cluster {lid}"
|
|
214
|
+
logger.warning("Failed to name cluster %s", lid, exc_info=True)
|
|
215
|
+
|
|
216
|
+
return data
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def save_clusters_json(
|
|
220
|
+
data: dict,
|
|
221
|
+
output_path: pathlib.Path,
|
|
222
|
+
) -> None:
|
|
223
|
+
"""Save cluster data to JSON."""
|
|
224
|
+
# Strip descriptions from the output to keep it lean; keep the UMAP
|
|
225
|
+
# coords so downstream exporters (the web explore map) reuse this
|
|
226
|
+
# projection instead of recomputing it.
|
|
227
|
+
out = {
|
|
228
|
+
"clusters": data["clusters"],
|
|
229
|
+
"coords": {
|
|
230
|
+
name: [round(c[0], 5), round(c[1], 5)] for name, c in data["coords"].items()
|
|
231
|
+
},
|
|
232
|
+
"generated_at": datetime.now(tz=timezone.utc).isoformat(),
|
|
233
|
+
"naming_model": CLUSTER_NAMING_MODEL,
|
|
234
|
+
}
|
|
235
|
+
output_path.write_text(json.dumps(out, indent=2, ensure_ascii=False))
|
|
236
|
+
logger.info("Saved clusters to %s", output_path)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def build_cluster_visualization(
|
|
240
|
+
data: dict,
|
|
241
|
+
output_path: pathlib.Path,
|
|
242
|
+
) -> None:
|
|
243
|
+
"""Build an interactive HTML scatter plot of the clusters."""
|
|
244
|
+
import colorsys # noqa: PLC0415
|
|
245
|
+
|
|
246
|
+
import plotly.graph_objects as go # noqa: PLC0415
|
|
247
|
+
|
|
248
|
+
clusters = data["clusters"]
|
|
249
|
+
coords = data["coords"]
|
|
250
|
+
descriptions = data["descriptions"]
|
|
251
|
+
|
|
252
|
+
real_clusters = [cid for cid in clusters if cid != "-1"]
|
|
253
|
+
n = len(real_clusters)
|
|
254
|
+
|
|
255
|
+
palette = []
|
|
256
|
+
for i in range(n):
|
|
257
|
+
h = i / n
|
|
258
|
+
r, g, b = colorsys.hsv_to_rgb(h, 0.7, 0.9)
|
|
259
|
+
palette.append(f"rgb({int(r * 255)},{int(g * 255)},{int(b * 255)})")
|
|
260
|
+
|
|
261
|
+
label_colors = {cid: palette[i] for i, cid in enumerate(real_clusters)}
|
|
262
|
+
label_colors["-1"] = "rgb(80,80,80)"
|
|
263
|
+
|
|
264
|
+
fig = go.Figure()
|
|
265
|
+
|
|
266
|
+
sorted_labels = sorted(real_clusters, key=lambda k: -len(clusters[k]["icons"]))
|
|
267
|
+
if "-1" in clusters:
|
|
268
|
+
sorted_labels.append("-1")
|
|
269
|
+
|
|
270
|
+
for lid in sorted_labels:
|
|
271
|
+
icons = clusters[lid]["icons"]
|
|
272
|
+
theme = clusters[lid].get("theme") or f"Cluster {lid}"
|
|
273
|
+
color = label_colors[lid]
|
|
274
|
+
is_noise = lid == "-1"
|
|
275
|
+
|
|
276
|
+
hover = [
|
|
277
|
+
f"<b>{name}</b><br><i>{theme}</i><br><br>{descriptions.get(name, '')[:150]}"
|
|
278
|
+
for name in icons
|
|
279
|
+
]
|
|
280
|
+
|
|
281
|
+
fig.add_trace(
|
|
282
|
+
go.Scatter(
|
|
283
|
+
x=[coords[n][0] for n in icons],
|
|
284
|
+
y=[coords[n][1] for n in icons],
|
|
285
|
+
mode="markers",
|
|
286
|
+
name=f"{theme} ({len(icons)})",
|
|
287
|
+
marker={
|
|
288
|
+
"size": 3 if is_noise else 5,
|
|
289
|
+
"color": color,
|
|
290
|
+
"opacity": 0.3 if is_noise else 0.8,
|
|
291
|
+
"line": {"width": 0.5, "color": "white"},
|
|
292
|
+
},
|
|
293
|
+
text=hover,
|
|
294
|
+
hoverinfo="text",
|
|
295
|
+
)
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
fig.update_layout(
|
|
299
|
+
title={
|
|
300
|
+
"text": (
|
|
301
|
+
"Lucide Icon Embedding Clusters \u2014 themes discovered via HDBSCAN"
|
|
302
|
+
),
|
|
303
|
+
"font": {"size": 16},
|
|
304
|
+
},
|
|
305
|
+
width=1400,
|
|
306
|
+
height=900,
|
|
307
|
+
template="plotly_dark",
|
|
308
|
+
legend={
|
|
309
|
+
"title": "Discovered Themes",
|
|
310
|
+
"font": {"size": 10},
|
|
311
|
+
"itemsizing": "constant",
|
|
312
|
+
"yanchor": "top",
|
|
313
|
+
"y": 1,
|
|
314
|
+
"xanchor": "left",
|
|
315
|
+
"x": 1.02,
|
|
316
|
+
},
|
|
317
|
+
xaxis={"showgrid": False, "showticklabels": False, "title": ""},
|
|
318
|
+
yaxis={"showgrid": False, "showticklabels": False, "title": ""},
|
|
319
|
+
hovermode="closest",
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
fig.write_html(str(output_path), include_plotlyjs=True, full_html=True)
|
|
323
|
+
size_mb = output_path.stat().st_size / (1024 * 1024)
|
|
324
|
+
logger.info("Visualization written: %s (%.1f MB)", output_path, size_mb)
|