You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
470 lines
20 KiB
470 lines
20 KiB
<script lang="ts"> |
|
import { CaretDownOutline, CaretUpOutline } from "flowbite-svelte-icons"; |
|
import { getEventKindColor, getEventKindName } from '$lib/utils/eventColors'; |
|
import type { EventCounts } from "$lib/types"; |
|
|
|
const TAG_LEGEND_COLUMNS = 3; // Number of columns for tag anchor table |
|
let { |
|
collapsedOnInteraction = false, |
|
className = "", |
|
starMode = false, |
|
showTags = false, |
|
tagAnchors = [], |
|
eventCounts = {}, |
|
disabledTags = new Set<string>(), |
|
onTagToggle = (tagId: string) => {}, |
|
autoDisabledTags = false, |
|
showTagAnchors = $bindable(false), |
|
selectedTagType = $bindable("t"), |
|
onTagSettingsChange = () => {}, |
|
showPersonNodes = $bindable(false), |
|
personAnchors = [], |
|
disabledPersons = new Set<string>(), |
|
onPersonToggle = (pubkey: string) => {}, |
|
onPersonSettingsChange = () => {}, |
|
showSignedBy = $bindable(true), |
|
showReferenced = $bindable(true), |
|
totalPersonCount = 0, |
|
displayedPersonCount = 0, |
|
} = $props<{ |
|
collapsedOnInteraction: boolean; |
|
className: string; |
|
starMode?: boolean; |
|
showTags?: boolean; |
|
tagAnchors?: any[]; |
|
eventCounts?: EventCounts; |
|
disabledTags?: Set<string>; |
|
onTagToggle?: (tagId: string) => void; |
|
autoDisabledTags?: boolean; |
|
showTagAnchors?: boolean; |
|
selectedTagType?: string; |
|
onTagSettingsChange?: () => void; |
|
showPersonNodes?: boolean; |
|
personAnchors?: any[]; |
|
disabledPersons?: Set<string>; |
|
onPersonToggle?: (pubkey: string) => void; |
|
onPersonSettingsChange?: () => void; |
|
showSignedBy?: boolean; |
|
showReferenced?: boolean; |
|
totalPersonCount?: number; |
|
displayedPersonCount?: number; |
|
}>(); |
|
|
|
let expanded = $state(true); |
|
let nodeTypesExpanded = $state(true); |
|
let tagAnchorsExpanded = $state(true); |
|
let tagControlsExpanded = $state(true); |
|
let personVisualizerExpanded = $state(true); |
|
let tagSortMode = $state<'count' | 'alphabetical'>('count'); |
|
|
|
$effect(() => { |
|
if (collapsedOnInteraction) { |
|
expanded = false; |
|
} |
|
}); |
|
|
|
function toggle() { |
|
expanded = !expanded; |
|
} |
|
|
|
function toggleNodeTypes() { |
|
nodeTypesExpanded = !nodeTypesExpanded; |
|
} |
|
|
|
function toggleTagAnchors() { |
|
tagAnchorsExpanded = !tagAnchorsExpanded; |
|
} |
|
|
|
function invertTagSelection() { |
|
// Invert selection - toggle all tags one by one |
|
const allTagIds = tagAnchors.map((anchor: any) => `${anchor.type}-${anchor.label}`); |
|
|
|
// Process all tags |
|
allTagIds.forEach((tagId: string) => { |
|
onTagToggle(tagId); |
|
}); |
|
} |
|
|
|
function invertPersonSelection() { |
|
// Invert selection - toggle all person nodes |
|
const allPubkeys = personAnchors.map((person: any) => person.pubkey); |
|
|
|
// Process all persons |
|
allPubkeys.forEach((pubkey: string) => { |
|
onPersonToggle(pubkey); |
|
}); |
|
} |
|
</script> |
|
|
|
<div class={`leather-legend ${className}`}> |
|
<div class="flex items-center justify-between space-x-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md px-2 py-1 -mx-2 -my-1" onclick={toggle}> |
|
<h3 class="h-leather">Legend</h3> |
|
<div class="pointer-events-none"> |
|
{#if expanded} |
|
<CaretUpOutline /> |
|
{:else} |
|
<CaretDownOutline /> |
|
{/if} |
|
</div> |
|
</div> |
|
|
|
{#if expanded} |
|
<div class="space-y-4"> |
|
<!-- Node Types Section --> |
|
<div class="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 last:border-b-0 last:mb-0"> |
|
<div class="flex justify-between items-center cursor-pointer px-2 py-2 rounded hover:bg-gray-50 dark:hover:bg-gray-800 mb-3" onclick={toggleNodeTypes}> |
|
<h4 class="font-semibold text-gray-700 dark:text-gray-300 text-sm m-0">Node Types</h4> |
|
<div class="pointer-events-none"> |
|
{#if nodeTypesExpanded} |
|
<CaretUpOutline class="w-3 h-3" /> |
|
{:else} |
|
<CaretDownOutline class="w-3 h-3" /> |
|
{/if} |
|
</div> |
|
</div> |
|
|
|
{#if nodeTypesExpanded} |
|
<ul class="space-y-2"> |
|
<!-- Dynamic event kinds --> |
|
{#each Object.entries(eventCounts).sort(([a], [b]) => Number(a) - Number(b)) as [kindStr, count]} |
|
{@const kind = Number(kindStr)} |
|
{@const countNum = count as number} |
|
{@const color = getEventKindColor(kind)} |
|
{@const name = getEventKindName(kind)} |
|
{#if countNum > 0} |
|
<li class="flex items-center mb-2 last:mb-0"> |
|
<div class="flex items-center mr-2"> |
|
<span |
|
class="w-4 h-4 rounded-full" |
|
style="background-color: {color}" |
|
> |
|
</span> |
|
</div> |
|
<span class="text-sm text-gray-700 dark:text-gray-300"> |
|
{kind} - {name} ({countNum}) |
|
</span> |
|
</li> |
|
{/if} |
|
{/each} |
|
|
|
<!-- Connection lines --> |
|
<li class="flex items-center mb-2 last:mb-0"> |
|
<svg class="w-6 h-6 mr-2" viewBox="0 0 24 24"> |
|
<path |
|
d="M4 12h16M16 6l6 6-6 6" |
|
class="network-link-leather" |
|
stroke-width="2" |
|
fill="none" |
|
/> |
|
</svg> |
|
<span class="text-sm text-gray-700 dark:text-gray-300"> |
|
{#if starMode} |
|
Radial connections from centers to related events |
|
{:else} |
|
Arrows indicate relationships and sequence |
|
{/if} |
|
</span> |
|
</li> |
|
|
|
<!-- Edge colors for person connections --> |
|
{#if showPersonNodes && personAnchors.length > 0} |
|
<li class="flex items-center mb-2 last:mb-0"> |
|
<svg class="w-6 h-6 mr-2" viewBox="0 0 24 24"> |
|
<path |
|
d="M4 12h16" |
|
class="person-link-signed" |
|
stroke-width="2" |
|
fill="none" |
|
/> |
|
</svg> |
|
<span class="text-xs text-gray-700 dark:text-gray-300"> |
|
Authored by person |
|
</span> |
|
</li> |
|
<li class="flex items-center mb-2 last:mb-0"> |
|
<svg class="w-6 h-6 mr-2" viewBox="0 0 24 24"> |
|
<path |
|
d="M4 12h16" |
|
class="person-link-referenced" |
|
stroke-width="2" |
|
fill="none" |
|
/> |
|
</svg> |
|
<span class="text-xs text-gray-700 dark:text-gray-300"> |
|
References person |
|
</span> |
|
</li> |
|
{/if} |
|
</ul> |
|
{/if} |
|
</div> |
|
|
|
<!-- Tag Anchor Controls Section --> |
|
<div class="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 last:border-b-0 last:mb-0"> |
|
<div class="flex justify-between items-center cursor-pointer px-2 py-2 rounded hover:bg-gray-50 dark:hover:bg-gray-800 mb-3" onclick={() => tagControlsExpanded = !tagControlsExpanded}> |
|
<h4 class="font-semibold text-gray-700 dark:text-gray-300 text-sm m-0">Tag Anchor Controls</h4> |
|
<div class="pointer-events-none"> |
|
{#if tagControlsExpanded} |
|
<CaretUpOutline class="w-3 h-3" /> |
|
{:else} |
|
<CaretDownOutline class="w-3 h-3" /> |
|
{/if} |
|
</div> |
|
</div> |
|
|
|
{#if tagControlsExpanded} |
|
<div class="space-y-3"> |
|
<!-- Show Tag Anchors Toggle --> |
|
<div class="flex items-center space-x-2"> |
|
<button |
|
onclick={() => { |
|
showTagAnchors = !showTagAnchors; |
|
onTagSettingsChange(); |
|
}} |
|
class="px-2 py-1 border border-gray-300 dark:border-gray-700 rounded text-xs font-medium cursor-pointer transition min-w-[3rem] hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-primary-500 {showTagAnchors ? 'bg-blue-600 text-white border-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:text-white dark:border-blue-600 dark:hover:bg-blue-700' : 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300'}" |
|
> |
|
{showTagAnchors ? 'ON' : 'OFF'} |
|
</button> |
|
<span class="text-sm">Show Tag Anchors</span> |
|
</div> |
|
|
|
{#if showTagAnchors} |
|
<!-- Tag Type Selection --> |
|
<div> |
|
<label class="text-xs text-gray-600 dark:text-gray-400">Tag Type:</label> |
|
<select |
|
bind:value={selectedTagType} |
|
onchange={onTagSettingsChange} |
|
class="w-full text-xs bg-primary-0 dark:bg-primary-1000 border border-gray-300 dark:border-gray-700 rounded-md px-2 py-1 dark:text-white mt-1" |
|
> |
|
<option value="t">Hashtags</option> |
|
<option value="author">Authors</option> |
|
<option value="e">Event References</option> |
|
<option value="title">Titles</option> |
|
<option value="summary">Summaries</option> |
|
</select> |
|
|
|
</div> |
|
{/if} |
|
</div> |
|
{/if} |
|
</div> |
|
|
|
<!-- Tag Anchors section --> |
|
{#if showTags && tagAnchors.length > 0} |
|
<div class="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 last:border-b-0 last:mb-0"> |
|
<div class="flex justify-between items-center cursor-pointer px-2 py-2 rounded hover:bg-gray-50 dark:hover:bg-gray-800 mb-3" onclick={toggleTagAnchors}> |
|
<h4 class="font-semibold text-gray-700 dark:text-gray-300 text-sm m-0">Active Tag Anchors: {tagAnchors[0].type}</h4> |
|
<div class="pointer-events-none"> |
|
{#if tagAnchorsExpanded} |
|
<CaretUpOutline class="w-3 h-3" /> |
|
{:else} |
|
<CaretDownOutline class="w-3 h-3" /> |
|
{/if} |
|
</div> |
|
</div> |
|
|
|
{#if tagAnchorsExpanded} |
|
{@const sortedAnchors = tagSortMode === 'count' |
|
? [...tagAnchors].sort((a, b) => b.count - a.count) |
|
: [...tagAnchors].sort((a, b) => a.label.localeCompare(b.label)) |
|
} |
|
{#if autoDisabledTags} |
|
<div class="text-xs text-amber-600 dark:text-amber-400 mb-2 p-2 bg-amber-50 dark:bg-amber-900/20 rounded"> |
|
<strong>Note:</strong> All {tagAnchors.length} tags were auto-disabled to prevent graph overload. Click individual tags below to enable them. |
|
</div> |
|
{/if} |
|
|
|
<!-- Sort options and controls --> |
|
<div class="flex items-center justify-between gap-4 mb-3"> |
|
<div class="flex items-center gap-4"> |
|
<span class="text-xs text-gray-600 dark:text-gray-400">Sort by:</span> |
|
<label class="flex items-center gap-1 cursor-pointer"> |
|
<input |
|
type="radio" |
|
name="tagSort" |
|
value="count" |
|
bind:group={tagSortMode} |
|
class="w-3 h-3" |
|
/> |
|
<span class="text-xs">Count</span> |
|
</label> |
|
<label class="flex items-center gap-1 cursor-pointer"> |
|
<input |
|
type="radio" |
|
name="tagSort" |
|
value="alphabetical" |
|
bind:group={tagSortMode} |
|
class="w-3 h-3" |
|
/> |
|
<span class="text-xs">Alphabetical</span> |
|
</label> |
|
</div> |
|
|
|
<label class="flex items-center gap-1 cursor-pointer"> |
|
<input |
|
type="checkbox" |
|
onclick={invertTagSelection} |
|
class="w-3 h-3" |
|
/> |
|
<span class="text-xs">Invert Selection</span> |
|
</label> |
|
</div> |
|
|
|
<div |
|
class="grid gap-1 {tagAnchors.length > 20 ? 'max-h-96 overflow-y-auto pr-2' : ''}" |
|
style="grid-template-columns: repeat({TAG_LEGEND_COLUMNS}, 1fr);" |
|
> |
|
{#each sortedAnchors as anchor} |
|
{@const tagId = `${anchor.type}-${anchor.label}`} |
|
{@const isDisabled = disabledTags.has(tagId)} |
|
<button |
|
class="flex items-center gap-1 p-1 rounded w-full text-left border-none bg-none cursor-pointer transition hover:bg-black/5 dark:hover:bg-white/5 disabled:opacity-50" |
|
onclick={() => onTagToggle(tagId)} |
|
title={isDisabled ? `Click to show ${anchor.label}` : `Click to hide ${anchor.label}`} |
|
> |
|
<div class="flex items-center"> |
|
<span |
|
class="w-4.5 h-4.5 rounded-full border-2 border-white flex items-center justify-center" |
|
style="background-color: {anchor.color}; opacity: {isDisabled ? 0.3 : 1};" |
|
> |
|
<span class="text-xs text-white font-bold"> |
|
{anchor.type === "t" |
|
? "#" |
|
: anchor.type === "author" |
|
? "A" |
|
: anchor.type.charAt(0).toUpperCase()} |
|
</span> |
|
</span> |
|
</div> |
|
<span class="text-xs text-gray-700 dark:text-gray-300 truncate" style="opacity: {isDisabled ? 0.5 : 1};" title={anchor.label}> |
|
{anchor.label.length > 25 ? anchor.label.slice(0, 22) + '...' : anchor.label} |
|
{#if !isDisabled} |
|
<span class="text-gray-500 dark:text-gray-400">({anchor.count})</span> |
|
{/if} |
|
</span> |
|
</button> |
|
{/each} |
|
</div> |
|
{/if} |
|
</div> |
|
{/if} |
|
|
|
<!-- Person Visualizer Section --> |
|
<div class="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 last:border-b-0 last:mb-0"> |
|
<div class="flex justify-between items-center cursor-pointer px-2 py-2 rounded hover:bg-gray-50 dark:hover:bg-gray-800 mb-3" onclick={() => personVisualizerExpanded = !personVisualizerExpanded}> |
|
<h4 class="font-semibold text-gray-700 dark:text-gray-300 text-sm m-0">Person Visualizer</h4> |
|
<div class="pointer-events-none"> |
|
{#if personVisualizerExpanded} |
|
<CaretUpOutline class="w-3 h-3" /> |
|
{:else} |
|
<CaretDownOutline class="w-3 h-3" /> |
|
{/if} |
|
</div> |
|
</div> |
|
|
|
{#if personVisualizerExpanded} |
|
<div class="space-y-3"> |
|
<!-- Show Person Nodes Toggle --> |
|
<div class="flex items-center justify-between"> |
|
<div class="flex items-center space-x-2"> |
|
<button |
|
onclick={() => { |
|
showPersonNodes = !showPersonNodes; |
|
onPersonSettingsChange(); |
|
}} |
|
class="px-2 py-1 border border-gray-300 dark:border-gray-700 rounded text-xs font-medium cursor-pointer transition min-w-[3rem] hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-primary-500 {showPersonNodes ? 'bg-blue-600 text-white border-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:text-white dark:border-blue-600 dark:hover:bg-blue-700' : 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300'}" |
|
> |
|
{showPersonNodes ? 'ON' : 'OFF'} |
|
</button> |
|
<span class="text-sm">Show Person Nodes</span> |
|
</div> |
|
|
|
{#if showPersonNodes} |
|
<div class="flex items-center space-x-3 text-xs"> |
|
<label class="flex items-center space-x-1"> |
|
<input |
|
type="checkbox" |
|
bind:checked={showSignedBy} |
|
onchange={onPersonSettingsChange} |
|
class="w-3 h-3" |
|
/> |
|
<span>Signed by</span> |
|
</label> |
|
<label class="flex items-center space-x-1"> |
|
<input |
|
type="checkbox" |
|
bind:checked={showReferenced} |
|
onchange={onPersonSettingsChange} |
|
class="w-3 h-3" |
|
/> |
|
<span>Referenced</span> |
|
</label> |
|
</div> |
|
{/if} |
|
</div> |
|
|
|
{#if showPersonNodes && personAnchors.length > 0} |
|
<div class="flex items-center justify-between mb-2"> |
|
<p class="text-xs text-gray-600 dark:text-gray-400"> |
|
{#if totalPersonCount > displayedPersonCount} |
|
Displaying {displayedPersonCount} of {totalPersonCount} people found: |
|
{:else} |
|
{personAnchors.length} people found: |
|
{/if} |
|
</p> |
|
|
|
<label class="flex items-center gap-1 cursor-pointer"> |
|
<input |
|
type="checkbox" |
|
onclick={invertPersonSelection} |
|
class="w-3 h-3" |
|
/> |
|
<span class="text-xs">Invert Selection</span> |
|
</label> |
|
</div> |
|
|
|
<div |
|
class="grid gap-1 {personAnchors.length > 20 ? 'max-h-96 overflow-y-auto pr-2' : ''}" |
|
style="grid-template-columns: repeat(2, 1fr);" |
|
> |
|
{#each personAnchors as person} |
|
{@const isDisabled = disabledPersons.has(person.pubkey)} |
|
<button |
|
class="flex items-center gap-1 p-1 rounded w-full text-left border-none bg-none cursor-pointer transition hover:bg-black/5 dark:hover:bg-white/5 disabled:opacity-50" |
|
onclick={() => { |
|
if (showPersonNodes) { |
|
onPersonToggle(person.pubkey); |
|
} |
|
}} |
|
disabled={!showPersonNodes} |
|
title={!showPersonNodes ? 'Enable "Show Person Nodes" first' : isDisabled ? `Click to show ${person.displayName || person.pubkey}` : `Click to hide ${person.displayName || person.pubkey}`} |
|
> |
|
<div class="flex items-center"> |
|
<span |
|
class="inline-block w-3.5 h-3.5 rotate-45 border-2 border-white" |
|
style="background-color: {person.isFromFollowList ? getEventKindColor(3) : '#10B981'}; opacity: {isDisabled ? 0.3 : 1};" |
|
/> |
|
</div> |
|
<span class="text-xs text-gray-700 dark:text-gray-300" style="opacity: {isDisabled ? 0.5 : 1};"> |
|
{person.displayName || person.pubkey.slice(0, 8) + '...'} |
|
{#if !isDisabled} |
|
<span class="text-gray-500 dark:text-gray-400"> |
|
({person.signedByCount || 0}s/{person.referencedCount || 0}r) |
|
</span> |
|
{/if} |
|
</span> |
|
</button> |
|
{/each} |
|
</div> |
|
{:else if showPersonNodes} |
|
<p class="text-xs text-gray-500 dark:text-gray-400"> |
|
No people found in the current events. |
|
</p> |
|
{/if} |
|
</div> |
|
{/if} |
|
</div> |
|
</div> |
|
{/if} |
|
</div>
|
|
|