Archipelago — open-source initial import
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<div ref="containerRef" class="network-map-container">
|
||||
<svg ref="svgRef" class="w-full h-full"></svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import * as d3 from 'd3'
|
||||
|
||||
interface MapNode {
|
||||
did: string
|
||||
label: string
|
||||
trust_level: 'trusted' | 'observer' | 'untrusted'
|
||||
online: boolean
|
||||
app_count: number
|
||||
is_self: boolean
|
||||
}
|
||||
|
||||
interface MapLink {
|
||||
source: string
|
||||
target: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
nodes: MapNode[]
|
||||
links: MapLink[]
|
||||
}>()
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
const svgRef = ref<SVGSVGElement>()
|
||||
|
||||
type SimNode = MapNode & d3.SimulationNodeDatum
|
||||
type SimLink = d3.SimulationLinkDatum<SimNode> & { source: string | SimNode; target: string | SimNode }
|
||||
|
||||
let simulation: d3.Simulation<SimNode, SimLink> | null = null
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
const graphSignature = computed(() => JSON.stringify({
|
||||
nodes: props.nodes.map(n => [n.did, n.label, n.trust_level, n.online, n.app_count, n.is_self]),
|
||||
links: props.links.map(l => [l.source, l.target]),
|
||||
}))
|
||||
|
||||
function trustColor(level: string): string {
|
||||
switch (level) {
|
||||
case 'trusted': return '#4ade80'
|
||||
case 'observer': return '#fb923c'
|
||||
case 'untrusted': return '#ef4444'
|
||||
default: return '#9ca3af'
|
||||
}
|
||||
}
|
||||
|
||||
function nodeRadius(n: MapNode): number {
|
||||
return n.is_self ? 18 : Math.max(10, Math.min(16, 8 + n.app_count * 0.5))
|
||||
}
|
||||
|
||||
function render() {
|
||||
simulation?.stop()
|
||||
const svg = d3.select(svgRef.value!)
|
||||
svg.selectAll('*').remove()
|
||||
|
||||
const container = containerRef.value!
|
||||
const width = container.clientWidth
|
||||
const height = container.clientHeight
|
||||
|
||||
svg.attr('viewBox', `0 0 ${width} ${height}`)
|
||||
|
||||
const simNodes: SimNode[] = props.nodes.map(n => ({ ...n }))
|
||||
const simLinks: SimLink[] = props.links.map(l => ({ ...l }))
|
||||
|
||||
// Center the self-node
|
||||
const selfNode = simNodes.find(n => n.is_self)
|
||||
if (selfNode) {
|
||||
selfNode.fx = width / 2
|
||||
selfNode.fy = height / 2
|
||||
}
|
||||
|
||||
simulation = d3.forceSimulation(simNodes)
|
||||
.force('link', d3.forceLink<SimNode, SimLink>(simLinks).id(d => d.did).distance(120))
|
||||
.force('charge', d3.forceManyBody().strength(-300))
|
||||
.force('center', d3.forceCenter(width / 2, height / 2))
|
||||
.force('collision', d3.forceCollide<SimNode>().radius(d => nodeRadius(d) + 5))
|
||||
|
||||
const g = svg.append('g')
|
||||
|
||||
// Links
|
||||
const link = g.append('g')
|
||||
.selectAll('line')
|
||||
.data(simLinks)
|
||||
.join('line')
|
||||
.attr('stroke', (d: SimLink) => {
|
||||
const src = typeof d.source === 'object' ? d.source : simNodes.find(n => n.did === d.source)
|
||||
const tgt = typeof d.target === 'object' ? d.target : simNodes.find(n => n.did === d.target)
|
||||
return (src as MapNode)?.online && (tgt as MapNode)?.online ? '#4ade8060' : '#6b728050'
|
||||
})
|
||||
.attr('stroke-width', 2)
|
||||
.attr('stroke-dasharray', (d: SimLink) => {
|
||||
const src = typeof d.source === 'object' ? d.source : simNodes.find(n => n.did === d.source)
|
||||
const tgt = typeof d.target === 'object' ? d.target : simNodes.find(n => n.did === d.target)
|
||||
return (src as MapNode)?.online && (tgt as MapNode)?.online ? 'none' : '6 4'
|
||||
})
|
||||
|
||||
// Node groups
|
||||
const node = g.append('g')
|
||||
.selectAll<SVGGElement, SimNode>('g')
|
||||
.data(simNodes)
|
||||
.join('g')
|
||||
.attr('cursor', 'pointer')
|
||||
.call(d3.drag<SVGGElement, SimNode>()
|
||||
.on('start', (event, d) => {
|
||||
if (!event.active) simulation!.alphaTarget(0.3).restart()
|
||||
d.fx = d.x
|
||||
d.fy = d.y
|
||||
})
|
||||
.on('drag', (event, d) => {
|
||||
d.fx = event.x
|
||||
d.fy = event.y
|
||||
})
|
||||
.on('end', (event, d) => {
|
||||
if (!event.active) simulation!.alphaTarget(0)
|
||||
if (!d.is_self) { d.fx = null; d.fy = null }
|
||||
})
|
||||
)
|
||||
|
||||
// Node circles
|
||||
node.append('circle')
|
||||
.attr('r', d => nodeRadius(d))
|
||||
.attr('fill', d => trustColor(d.trust_level))
|
||||
.attr('fill-opacity', d => d.online ? 0.8 : 0.3)
|
||||
.attr('stroke', d => d.is_self ? '#fb923c' : trustColor(d.trust_level))
|
||||
.attr('stroke-width', d => d.is_self ? 3 : 1.5)
|
||||
.attr('stroke-opacity', d => d.online ? 1 : 0.4)
|
||||
|
||||
// Node labels
|
||||
node.append('text')
|
||||
.text(d => d.label)
|
||||
.attr('dy', d => nodeRadius(d) + 14)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('fill', 'rgba(255,255,255,0.7)')
|
||||
.attr('font-size', '11px')
|
||||
.attr('font-family', "'Avenir Next', sans-serif")
|
||||
|
||||
// Tooltip
|
||||
node.append('title')
|
||||
.text(d => `${d.did}\nApps: ${d.app_count}\n${d.online ? 'Online' : 'Offline'}`)
|
||||
|
||||
simulation.on('tick', () => {
|
||||
link
|
||||
.attr('x1', d => (d.source as SimNode).x!)
|
||||
.attr('y1', d => (d.source as SimNode).y!)
|
||||
.attr('x2', d => (d.target as SimNode).x!)
|
||||
.attr('y2', d => (d.target as SimNode).y!)
|
||||
|
||||
node.attr('transform', d => `translate(${d.x},${d.y})`)
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
render()
|
||||
resizeObserver = new ResizeObserver(() => render())
|
||||
if (containerRef.value) resizeObserver.observe(containerRef.value)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
simulation?.stop()
|
||||
resizeObserver?.disconnect()
|
||||
})
|
||||
|
||||
watch(graphSignature, () => render())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.network-map-container {
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(24px);
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22);
|
||||
min-height: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<BaseModal
|
||||
:show="show"
|
||||
title="Request to Peer"
|
||||
max-width="max-w-md"
|
||||
:z-index="'z-[3200]'"
|
||||
@close="$emit('cancel')"
|
||||
>
|
||||
<p class="text-white/70 text-sm mb-1">
|
||||
Send a connection request to
|
||||
<span class="text-white font-medium">{{ targetLabel }}</span>
|
||||
</p>
|
||||
<p class="text-white/45 text-xs mb-4">
|
||||
They'll see your request and approve or decline it. Approved peers connect
|
||||
at the Peer level — never trusted automatically.
|
||||
</p>
|
||||
|
||||
<label class="block text-xs text-white/60 mb-1">Message (optional)</label>
|
||||
<textarea
|
||||
v-model="message"
|
||||
rows="3"
|
||||
maxlength="280"
|
||||
placeholder="Hey — mind if we peer?"
|
||||
class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60 resize-none"
|
||||
></textarea>
|
||||
<p class="text-[11px] text-white/30 text-right mt-1">{{ message.length }}/280</p>
|
||||
|
||||
<div class="flex gap-2 mt-4">
|
||||
<button class="flex-1 glass-button px-4 py-2 rounded-lg text-sm" @click="$emit('cancel')">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
|
||||
:disabled="sending"
|
||||
@click="$emit('send', message.trim() || undefined)"
|
||||
>
|
||||
{{ sending ? 'Sending…' : 'Send Request' }}
|
||||
</button>
|
||||
</div>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import BaseModal from '@/components/BaseModal.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
targetLabel: string
|
||||
sending?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
send: [message: string | undefined]
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const message = ref('')
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(visible) => {
|
||||
if (visible) message.value = ''
|
||||
},
|
||||
)
|
||||
</script>
|
||||
Reference in New Issue
Block a user