a bunch of frontend vibecoding. I hate that this is so much fun. No we have a graph view of all contacts.
This commit is contained in:
@@ -34,6 +34,7 @@ async function select_module(name, btn) {
|
||||
// workaround to execute script tags in loaded html
|
||||
content.querySelectorAll('script').forEach(oldScript => {
|
||||
const newScript = document.createElement('script');
|
||||
newScript.async = false;
|
||||
if (oldScript.src) newScript.src = oldScript.src;
|
||||
else newScript.textContent = oldScript.textContent;
|
||||
document.body.appendChild(newScript);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# how many events per week will result in an perfect event score (float)
|
||||
FREQUENCY_TARGET_WEEKLY=
|
||||
# how many different people per month will result in an perfect diversity score (float)
|
||||
DIVERSITY_TARGET_MONTHLY=
|
||||
|
||||
# full path to one carddav addressbook
|
||||
CARDDAV_URL=https://
|
||||
|
||||
CARDDAV_USER=
|
||||
CARDDAV_PASSWORD=
|
||||
|
||||
# adding this to a contacts "notes" section excludes them
|
||||
CARDDAV_IGNORE_STR=<ignore>
|
||||
|
||||
# full path to one specific calendar
|
||||
CALDAV_URL=https://
|
||||
|
||||
CALDAV_USER=
|
||||
CALDAV_PASSWORD=
|
||||
|
||||
# prefix to mark event participants in calendar event description like "@max mustermann\n"
|
||||
CALDAV_PARTICIPANT_PREFIX=@
|
||||
|
||||
# delay between attempts to sync data from servers (int)
|
||||
REFETCH_SECONDS=300
|
||||
@@ -3,6 +3,7 @@
|
||||
<link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@500;600;700&family=IBM+Plex+Sans:wght@400;500&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/modules/socialwatch/static/_style.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.9.0/d3.min.js"></script>
|
||||
|
||||
<div class="sw">
|
||||
<div class="wrap">
|
||||
@@ -56,7 +57,7 @@
|
||||
<div class="stat-num" id="social-score">–</div>
|
||||
<div class="stat-sub">social score</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card clickable" id="events-card">
|
||||
<div class="stat-num" id="events-month">–</div>
|
||||
<div class="stat-sub">events / mo</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,9 @@ AW.define(() => {
|
||||
const now = new Date();
|
||||
$('#dateline').textContent = now.toLocaleDateString('en-GB',{weekday:'short',day:'2-digit',month:'short',year:'numeric'});
|
||||
|
||||
let people = [], events = [], parseErrors = [];
|
||||
let people = [], events = [], parseErrors = [], stats = {};
|
||||
let graphSimulation = null;
|
||||
let graphResizeObserver = null;
|
||||
|
||||
async function loadData(){
|
||||
try{
|
||||
@@ -271,6 +273,9 @@ AW.define(() => {
|
||||
}
|
||||
function closeModal(){
|
||||
overlay.classList.remove('open');
|
||||
$('#modal').classList.remove('graph-modal');
|
||||
if(graphResizeObserver){ graphResizeObserver.disconnect(); graphResizeObserver = null; }
|
||||
if(graphSimulation){ graphSimulation.stop(); graphSimulation = null; }
|
||||
}
|
||||
$('#modal-close').addEventListener('click', closeModal);
|
||||
overlay.addEventListener('click', e => { if(e.target === overlay) closeModal(); });
|
||||
@@ -319,7 +324,6 @@ AW.define(() => {
|
||||
`);
|
||||
|
||||
if(person && person.photo){
|
||||
console.log("loading picture")
|
||||
loadAvatar(uid).then(src => {
|
||||
const el = $('#modal-avatar');
|
||||
if(el) el.style.backgroundImage = `url(${src})`;
|
||||
@@ -397,8 +401,123 @@ AW.define(() => {
|
||||
`);
|
||||
}
|
||||
|
||||
// ---- graph modal ----
|
||||
function openGraphModal(){
|
||||
$('#modal').classList.add('graph-modal');
|
||||
openModal(`
|
||||
<div class="modal-kicker">Social Graph</div>
|
||||
<div class="modal-title"><i class="fa-solid fa-diagram-project"></i> SocialGraph </div>
|
||||
<div id="graph-wrap"><svg id="graph-svg"></svg></div>
|
||||
`);
|
||||
|
||||
const wrap = document.getElementById('graph-wrap');
|
||||
const svg = d3.select('#graph-svg');
|
||||
const g = svg.append('g');
|
||||
|
||||
svg.call(d3.zoom().scaleExtent([0.3, 4]).filter(event => !event.target.closest('g')).on('zoom', e => g.attr('transform', e.transform)));
|
||||
|
||||
// build nodes + edges from shared events
|
||||
const nodesMap = new Map();
|
||||
const edgeMap = new Map();
|
||||
events.forEach(ev => {
|
||||
const parts = (ev.participants || []).map(participantUid);
|
||||
parts.forEach(uid => {
|
||||
if(!nodesMap.has(uid)) nodesMap.set(uid, { id: uid, name: participantName(uid), count: 0 });
|
||||
nodesMap.get(uid).count++;
|
||||
});
|
||||
for(let i = 0; i < parts.length; i++)
|
||||
for(let j = i+1; j < parts.length; j++){
|
||||
const [a,b] = [parts[i], parts[j]].sort();
|
||||
const key = a + '|' + b;
|
||||
if(!edgeMap.has(key)) edgeMap.set(key, { source: a, target: b, count: 0 });
|
||||
edgeMap.get(key).count++;
|
||||
}
|
||||
});
|
||||
const nodes = Array.from(nodesMap.values());
|
||||
const links = Array.from(edgeMap.values());
|
||||
const seedW = wrap.clientWidth || 600, seedH = wrap.clientHeight || 400;
|
||||
nodes.forEach(n => {
|
||||
n.x = seedW / 2 + (Math.random() - 0.5) * 100;
|
||||
n.y = seedH / 2 + (Math.random() - 0.5) * 100;
|
||||
});
|
||||
|
||||
const linkSel = g.append('g').selectAll('line').data(links).join('line')
|
||||
.attr('stroke', 'var(--border)')
|
||||
.attr('stroke-width', d => 1 + Math.sqrt(d.count));
|
||||
|
||||
const nodeSel = g.append('g').selectAll('g').data(nodes).join('g')
|
||||
.call(drag());
|
||||
|
||||
nodeSel.each(function(d, i){
|
||||
const person = findPerson(d.id);
|
||||
const r = 5 + Math.min(14, Math.sqrt(d.count) * 3);
|
||||
const sel = d3.select(this);
|
||||
|
||||
// fallback circle always drawn first (shows while photo loads / if no photo)
|
||||
sel.append('circle')
|
||||
.attr('r', r)
|
||||
.attr('fill', 'var(--primary)');
|
||||
|
||||
if(person && person.photo){
|
||||
const clipId = `node-clip-${i}`;
|
||||
sel.append('clipPath').attr('id', clipId)
|
||||
.append('circle').attr('r', r);
|
||||
|
||||
const img = sel.append('image')
|
||||
.attr('x', -r).attr('y', -r)
|
||||
.attr('width', r * 2).attr('height', r * 2)
|
||||
.attr('clip-path', `url(#${clipId})`)
|
||||
.attr('preserveAspectRatio', 'xMidYMid slice');
|
||||
|
||||
loadAvatar(d.id).then(src => { if(src) img.attr('href', src); });
|
||||
}
|
||||
|
||||
sel.append('text')
|
||||
.attr('x', r + 5).attr('y', 4)
|
||||
.attr('fill', 'var(--text)')
|
||||
.style('font-size', '11px')
|
||||
.text(d.name);
|
||||
});
|
||||
|
||||
function drag(){
|
||||
return d3.drag()
|
||||
.on('start', (event, d) => { if(!event.active) graphSimulation.alphaTarget(0.25).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) graphSimulation.alphaTarget(0); d.fx = null; d.fy = null; });
|
||||
}
|
||||
|
||||
function tick(){
|
||||
linkSel.attr('x1', d => d.source.x).attr('y1', d => d.source.y)
|
||||
.attr('x2', d => d.target.x).attr('y2', d => d.target.y);
|
||||
nodeSel.attr('transform', d => `translate(${d.x},${d.y})`);
|
||||
}
|
||||
|
||||
// more shared events -> shorter link distance -> nodes pulled closer
|
||||
graphResizeObserver = new ResizeObserver(() => {
|
||||
const w = wrap.clientWidth, h = wrap.clientHeight;
|
||||
svg.attr('viewBox', [0, 0, w, h]);
|
||||
if(!graphSimulation){
|
||||
graphSimulation = d3.forceSimulation(nodes)
|
||||
.force('link', d3.forceLink(links).id(d => d.id).distance(d => 140 - Math.min(110, d.count * 20)))
|
||||
.force('charge', d3.forceManyBody().strength(-180))
|
||||
.force('center', d3.forceCenter(w/2, h/2))
|
||||
.force('collide', d3.forceCollide().radius(24))
|
||||
.force('x', d3.forceX(w/2).strength(0.05))
|
||||
.force('y', d3.forceY(h/2).strength(0.05))
|
||||
.on('tick', tick);
|
||||
} else {
|
||||
graphSimulation.force('center', d3.forceCenter(w/2, h/2))
|
||||
.force('x', d3.forceX(w/2).strength(0.05))
|
||||
.force('y', d3.forceY(h/2).strength(0.05))
|
||||
.alpha(0.3).restart();
|
||||
}
|
||||
});
|
||||
graphResizeObserver.observe(wrap);
|
||||
}
|
||||
|
||||
$('#parse-fab').addEventListener('click', openParseErrorsModal);
|
||||
$('#score-card').addEventListener('click', openScoreModal);
|
||||
$('#events-card').addEventListener('click', openGraphModal);
|
||||
|
||||
// event delegation for the whole page + modal content
|
||||
document.addEventListener('click', e => {
|
||||
|
||||
@@ -196,6 +196,26 @@
|
||||
color: var(--border);
|
||||
}
|
||||
|
||||
/* graph modal — #modal-body itself must be flex so #graph-wrap can flex:1 */
|
||||
.sw .modal.graph-modal{
|
||||
max-width:90vw; width:90vw;
|
||||
height:80vh; max-height:80vh;
|
||||
display:flex; flex-direction:column;
|
||||
overflow:hidden;
|
||||
}
|
||||
.sw .modal.graph-modal #modal-body{
|
||||
display:flex; flex-direction:column;
|
||||
flex:1; min-height:0;
|
||||
}
|
||||
.sw .modal.graph-modal #graph-wrap{
|
||||
flex:1; min-height:0;
|
||||
height:auto; width:100%;
|
||||
}
|
||||
.sw #graph-svg{ width:100%; height:100%; display:block; cursor:grab; }
|
||||
.sw #graph-svg:active{ cursor:grabbing; }
|
||||
.sw #graph-svg .node{ cursor: grab; }
|
||||
.sw #graph-svg .node:active{ cursor: grabbing; }
|
||||
|
||||
@media (max-width:980px){
|
||||
.sw{height:auto;overflow-y:auto;overflow-x:hidden;padding:1rem}
|
||||
.sw main.grid{grid-template-columns:1fr;display:flex;flex-direction:column}
|
||||
|
||||
Reference in New Issue
Block a user