feat: URL share / edge delete / AI context hint

This commit is contained in:
posimai 2026-03-29 17:06:44 +09:00
parent a61ebf7a02
commit 0252ee9315
1 changed files with 137 additions and 1 deletions

View File

@ -680,6 +680,51 @@
margin: 0; margin: 0;
} }
/* ── Share banner ──────────────────────────────────── */
#share-banner {
position: fixed;
top: 52px;
left: 0; right: 0;
z-index: 15;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 7px 16px;
background: rgba(34,211,238,0.08);
border-bottom: 1px solid rgba(34,211,238,0.18);
font-size: 12px;
color: var(--accent);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
#share-banner[hidden] { display: none; }
.has-banner #graph-wrap { top: 88px; }
.has-banner #filter-bar { top: 96px; }
/* ── Edge delete button ─────────────────────────────── */
.conn-delete-btn {
flex-shrink: 0;
width: 20px; height: 20px;
border-radius: 5px;
background: transparent;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--text3);
opacity: 0;
transition: opacity 0.15s, background 0.15s, color 0.15s;
padding: 0;
margin-left: auto;
}
.conn-item:hover .conn-delete-btn { opacity: 1; }
.conn-delete-btn:hover {
background: rgba(239,68,68,0.15);
color: #F87171;
}
/* ── Edge label tooltip ────────────────────────────── */ /* ── Edge label tooltip ────────────────────────────── */
#edge-tooltip { #edge-tooltip {
position: fixed; position: fixed;
@ -740,6 +785,13 @@
</aside> </aside>
<div class="overlay" id="overlay" aria-hidden="true"></div> <div class="overlay" id="overlay" aria-hidden="true"></div>
<!-- Share banner (read-only mode) -->
<div id="share-banner" hidden>
<i data-lucide="eye" style="width:13px;height:13px;stroke-width:1.75"></i>
<span>シェアビュー(読み取り専用)</span>
<button id="share-banner-own-btn" style="margin-left:12px;font-size:11px;padding:3px 10px;border-radius:8px;background:var(--accent-dim);border:1px solid rgba(34,211,238,0.3);color:var(--accent);cursor:pointer">自分のデータを開く</button>
</div>
<!-- Aurora --> <!-- Aurora -->
<div class="aurora-bg" aria-hidden="true"> <div class="aurora-bg" aria-hidden="true">
<div class="aurora-blob aurora-blob-1"></div> <div class="aurora-blob aurora-blob-1"></div>
@ -909,6 +961,9 @@
</button> </button>
</div> </div>
</div> </div>
<div style="padding:8px 16px;font-size:11px;color:var(--text3);border-bottom:1px solid var(--border);line-height:1.6">
Claude や Gemini のチャットに貼り付けると、AI があなたの環境を即座に把握します
</div>
<pre id="ai-context-text"></pre> <pre id="ai-context-text"></pre>
</div> </div>
</div> </div>
@ -918,6 +973,9 @@
<button class="tb-btn accent-btn" id="btn-ai" aria-label="AI Context を生成" title="AI Context"> <button class="tb-btn accent-btn" id="btn-ai" aria-label="AI Context を生成" title="AI Context">
<i data-lucide="brain-circuit" style="width:17px;height:17px;stroke-width:1.5"></i> <i data-lucide="brain-circuit" style="width:17px;height:17px;stroke-width:1.5"></i>
</button> </button>
<button class="tb-btn" id="btn-share" aria-label="URLでシェア" title="URLでシェア">
<i data-lucide="share-2" style="width:16px;height:16px;stroke-width:1.75"></i>
</button>
<div class="tb-divider"></div> <div class="tb-divider"></div>
<button class="tb-btn" id="btn-add-node" aria-label="ノードを追加" title="ノードを追加"> <button class="tb-btn" id="btn-add-node" aria-label="ノードを追加" title="ノードを追加">
<i data-lucide="plus" style="width:17px;height:17px;stroke-width:1.75"></i> <i data-lucide="plus" style="width:17px;height:17px;stroke-width:1.75"></i>
@ -1017,6 +1075,9 @@ let hiddenTypes = new Set();
let simulation = null; let simulation = null;
let svgZoom = null; let svgZoom = null;
let svgG = null; let svgG = null;
let isReadOnly = false;
let currentSimNodes = [];
let currentSimEdges = [];
async function loadData() { async function loadData() {
const saved = localStorage.getItem(STORAGE_KEY); const saved = localStorage.getItem(STORAGE_KEY);
@ -1073,12 +1134,14 @@ function initGraph() {
// Clone objects for simulation (D3 mutates them) // Clone objects for simulation (D3 mutates them)
const simNodes = nodes.map(n => ({ ...n })); const simNodes = nodes.map(n => ({ ...n }));
currentSimNodes = simNodes;
const nodeMap = new Map(simNodes.map(n => [n.id, n])); const nodeMap = new Map(simNodes.map(n => [n.id, n]));
const simEdges = edges.map(e => ({ const simEdges = edges.map(e => ({
...e, ...e,
source: nodeMap.get(e.from) || e.from, source: nodeMap.get(e.from) || e.from,
target: nodeMap.get(e.to) || e.to, target: nodeMap.get(e.to) || e.to,
})); }));
currentSimEdges = simEdges;
// Simulation // Simulation
simulation = d3.forceSimulation(simNodes) simulation = d3.forceSimulation(simNodes)
@ -1272,11 +1335,16 @@ function showDetail(id, simNodes, simEdges) {
const otherId = isOut ? e.to : e.from; const otherId = isOut ? e.to : e.from;
const other = atlasData.nodes.find(n => n.id === otherId); const other = atlasData.nodes.find(n => n.id === otherId);
const color = TYPE_COLORS[other?.type] || '#9CA3AF'; const color = TYPE_COLORS[other?.type] || '#9CA3AF';
const delBtn = isReadOnly ? '' :
`<button class="conn-delete-btn" onclick="deleteEdge('${e.from}','${e.to}')" aria-label="接続を削除" title="削除">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>`;
return `<div class="conn-item"> return `<div class="conn-item">
<span class="conn-dot" style="background:${color}"></span> <span class="conn-dot" style="background:${color}"></span>
<span>${other?.label || otherId}</span> <span>${other?.label || otherId}</span>
${e.label ? `<span style="font-size:10px;color:var(--text3)">${e.label}</span>` : ''} ${e.label ? `<span style="font-size:10px;color:var(--text3)">${e.label}</span>` : ''}
<span class="conn-dir">${isOut ? 'out' : 'in'}</span> <span class="conn-dir" style="margin-left:auto;margin-right:4px">${isOut ? 'out' : 'in'}</span>
${delBtn}
</div>`; </div>`;
}).join(''); }).join('');
} }
@ -1505,6 +1573,19 @@ function exportJson() {
function bindEvents() { function bindEvents() {
document.getElementById('btn-fit').addEventListener('click', fitGraph); document.getElementById('btn-fit').addEventListener('click', fitGraph);
document.getElementById('btn-add-node').addEventListener('click', openAddModal); document.getElementById('btn-add-node').addEventListener('click', openAddModal);
document.getElementById('btn-share').addEventListener('click', () => {
const url = generateShareURL();
navigator.clipboard.writeText(url)
.then(() => showToast('シェア URL をコピーしました'))
.catch(() => {
// Fallback: prompt
prompt('シェア URL をコピーしてください', url);
});
});
document.getElementById('share-banner-own-btn').addEventListener('click', () => {
history.replaceState(null, '', location.pathname);
location.reload();
});
document.getElementById('btn-ai').addEventListener('click', () => { document.getElementById('btn-ai').addEventListener('click', () => {
document.getElementById('ai-context-text').textContent = generateAIContext(); document.getElementById('ai-context-text').textContent = generateAIContext();
openModal('ai-modal-overlay'); openModal('ai-modal-overlay');
@ -1591,6 +1672,52 @@ if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js'); navigator.serviceWorker.register('/sw.js');
} }
// ── URL Sharing ────────────────────────────────────────────────
function generateShareURL() {
const json = JSON.stringify(atlasData);
const b64 = btoa(unescape(encodeURIComponent(json)));
return `${location.origin}${location.pathname}#atlas=${b64}`;
}
function checkShareURL() {
const hash = location.hash;
if (!hash.startsWith('#atlas=')) return false;
try {
const b64 = hash.slice(7);
const json = decodeURIComponent(escape(atob(b64)));
const data = JSON.parse(json);
if (!Array.isArray(data.nodes) || !Array.isArray(data.edges)) return false;
atlasData = data;
isReadOnly = true;
return true;
} catch (e) {
return false;
}
}
function applyReadOnly() {
const banner = document.getElementById('share-banner');
banner.removeAttribute('hidden');
document.body.classList.add('has-banner');
if (window.lucide) lucide.createIcons({ nodes: [banner] });
document.getElementById('btn-add-node').style.display = 'none';
}
// ── Edge delete ────────────────────────────────────────────────
function deleteEdge(from, to) {
if (isReadOnly) return;
atlasData.edges = atlasData.edges.filter(e => !(e.from === from && e.to === to));
saveData();
const keepId = selectedNodeId;
initGraph();
if (keepId) {
selectedNodeId = keepId;
updateHighlight(currentSimNodes, currentSimEdges);
showDetail(keepId, currentSimNodes, currentSimEdges);
}
showToast('接続を削除しました');
}
// ── Wizard ───────────────────────────────────────────────────── // ── Wizard ─────────────────────────────────────────────────────
function showWizard() { function showWizard() {
const overlay = document.getElementById('wizard-overlay'); const overlay = document.getElementById('wizard-overlay');
@ -1640,6 +1767,15 @@ function handleImportFile(file, isWizard) {
// ── Init ─────────────────────────────────────────────────────── // ── Init ───────────────────────────────────────────────────────
loadData().then(() => { loadData().then(() => {
bindEvents(); bindEvents();
// 1. Check for shared atlas in URL hash (read-only mode)
if (checkShareURL()) {
buildFilterBar();
initGraph();
applyReadOnly();
setTimeout(fitGraph, 800);
return;
}
// 2. Normal flow
if (!atlasData) { if (!atlasData) {
showWizard(); showWizard();
} else { } else {