damogn
Hero
- Joined
- 4 Feb 2018
- Messages
- 1,125
Om rösträknaren är nere så kan man räkna röster med hjälp av sin egen webbläsare:
1) Skapa ett bokmärke (på vilken sida som helst).
2) Editera det. Sätt namn till vad som helst (t.ex. Rösträknare).
3) I URL tar du bort allt som står och klistrar in allt detta:
4) När du sedan öppnar en runda och trycker på bokmärket (och väntar några sekunder) så kommer den bygga tabellen längst ner på sidan och scrolla dit åt dig så att du kan se röstläget.
OBS: Du måste alltså vara inne på själva rollspel.nu-tråden när du klickar bokmärket.
Skriptet tittar efter röster på följande sätt:
- Ordet röst (med stora eller små bokstäver), följt av mellanslag (eller inte), följt av kolon
), följt av mellanslag, följt av en äkta forum-tagg (dvs en klickbar @-mention).
Så t.ex. Röst: @Sysp - In i spelet (som det stod i ett parti nyligen) blir bara en röst på Sysp och inte på "Sysp - In i spelet" (kom ihåg att forumet tillåter mellanslag i användarnamnet, så man behöver vara försiktig på vad man tittar efter).
Man kan alltså skriva
Röst : @Namn
Röst: @Namn
Röst: @Namn
röst: @Namn - Nu är jag trött på dig @Namn.
osv.
EDIT: En lite större variant där man kan analysera rösterna bättre. Denna "skriver över" fliken du har öppen (eftersom den tar mer plats), men laddar du om sidan kommer du tillbaka.
För ett arkiv som har alla gamla spel i sig (uppdaterar det med jämna mellanrum) så finns: https://damianoognissanti.github.io/varulv-rostraknare/
1) Skapa ett bokmärke (på vilken sida som helst).
2) Editera det. Sätt namn till vad som helst (t.ex. Rösträknare).
3) I URL tar du bort allt som står och klistrar in allt detta:
Code:
javascript:(async function(){
const currentUrl=window.location.href;
const match=currentUrl.match(/(https:\/\/www\.rollspel\.nu\/threads\/[^\/]+\/)/);
if(!match){alert("Kunde inte hitta trådens URL.");return;}
const threadUrl=match[1];
const voteStart=/\bröst\s*:\s*/i;
const userTag=/data-username="@([^"]+)"/i;
const votes=[];
const firstAfter=function(line){
const d=document.createElement("div");
d.innerHTML=line;
const w=document.createTreeWalker(d,NodeFilter.SHOW_TEXT|NodeFilter.SHOW_ELEMENT);
let seen=false,buf="";
while(w.nextNode()){
const n=w.currentNode;
if(n.nodeType===3){
buf+=(n.nodeValue||"");
if(!seen&&voteStart.test(buf)) seen=true;
if(buf.length>200) buf=buf.slice(-200);
}else if(seen&&n.nodeType===1){
const du=n.getAttribute&&n.getAttribute("data-username");
if(du){
const m=du.match(/^@(.+)$/);
if(m) return m[1].trim();
}
}
}
return null;
};
for(let page=1;;page++){
const url=page===1?threadUrl:`${threadUrl}page-${page}`;
try{
const response=await fetch(url);
if(!response.ok)throw new Error(`Misslyckades att hämta sida ${page}`);
const html=await response.text();
const doc=new DOMParser().parseFromString(html,'text/html');
const posts=doc.querySelectorAll('article[data-author]');
posts.forEach(post=>{
const username=post.getAttribute('data-author')||'';
post.querySelectorAll('blockquote').forEach(bq=>bq.remove());
const content=post.querySelector('.message-content')?.innerHTML;
const postId=post.id?.replace('js-post-','');
if(content){
content.split(/\n|<br\s*\/?>/i).forEach(line=>{
const plain=line.replace(/<[^>]+>/g,' ');
if(!voteStart.test(plain)) return;
const to=firstAfter(line);
if(to&&postId){
votes.push({from:username,to:to,postId});
}
});
}
});
const nextPage=doc.querySelector('.pageNav-jump--next');
if(!nextPage)break;
}catch(err){
console.error(err);
break;
}
}
const votesByUser={};
votes.forEach(v=>{
if(!votesByUser[v.from])votesByUser[v.from]=[];
votesByUser[v.from].push(v);
});
const voteCounts={};
Object.values(votesByUser).forEach(voteList=>{
const last=voteList[voteList.length-1];
if(!last) return;
voteCounts[last.to]=(voteCounts[last.to]||0)+1;
});
let mostVoted=null;
let mostVotes=-1;
for(const[person,count]of Object.entries(voteCounts)){
if(count>mostVotes){
mostVotes=count;
mostVoted=person;
}
}
const old=document.getElementById('wwVotesWrap');
if(old) old.remove();
const table=document.createElement('table');
table.style.borderCollapse='collapse';
table.style.marginTop='20px';
table.style.fontFamily='Arial, sans-serif';
table.style.width='100%';
table.style.maxWidth='800px';
table.style.margin='20px auto';
table.style.border='2px solid #333';
table.style.backgroundColor='#f4f4f9';
table.innerHTML=`<thead>
<tr>
<th style="border:1px solid #333;background-color:#3c8dbc;color:white;padding:8px;font-size:18px;">Röstgivare</th>
<th style="border:1px solid #333;background-color:#3c8dbc;color:white;padding:8px;font-size:18px;">Röster</th>
</tr>
</thead>`;
const tbody=document.createElement('tbody');
Object.entries(votesByUser).forEach(([from,voteList])=>{
const tr=document.createElement('tr');
tr.style.backgroundColor='#ffffff';
tr.style.borderBottom='1px solid #ddd';
const voteLinks=voteList.map(v=>
`<a href="${threadUrl}post-${v.postId}" target="_blank" style="color:#007bff;">${v.to}</a>`
);
tr.innerHTML=`
<td style="border:1px solid #333;padding:8px;text-align:center;font-size:16px;">${from}</td>
<td style="border:1px solid #333;padding:8px;text-align:center;font-size:16px;">${voteLinks.join(', ')}</td>
`;
tbody.appendChild(tr);
});
table.appendChild(tbody);
const hr=document.createElement('hr');
hr.style.margin='20px 0';
hr.style.borderTop='3px solid #333';
const dangerText=document.createElement('div');
dangerText.textContent=`Risk för utröstning: ${mostVoted} (${mostVotes})`;
dangerText.style.fontSize='22px';
dangerText.style.fontWeight='bold';
dangerText.style.color='#d9534f';
dangerText.style.textAlign='center';
const wrap=document.createElement('div');
wrap.id='wwVotesWrap';
wrap.appendChild(table);
wrap.appendChild(hr);
wrap.appendChild(dangerText);
document.body.appendChild(wrap);
wrap.scrollIntoView({behavior:'smooth',block:'start'});
})();
OBS: Du måste alltså vara inne på själva rollspel.nu-tråden när du klickar bokmärket.
Skriptet tittar efter röster på följande sätt:
- Ordet röst (med stora eller små bokstäver), följt av mellanslag (eller inte), följt av kolon
Så t.ex. Röst: @Sysp - In i spelet (som det stod i ett parti nyligen) blir bara en röst på Sysp och inte på "Sysp - In i spelet" (kom ihåg att forumet tillåter mellanslag i användarnamnet, så man behöver vara försiktig på vad man tittar efter).
Man kan alltså skriva
Röst : @Namn
Röst: @Namn
Röst: @Namn
röst: @Namn - Nu är jag trött på dig @Namn.
osv.
EDIT: En lite större variant där man kan analysera rösterna bättre. Denna "skriver över" fliken du har öppen (eftersom den tar mer plats), men laddar du om sidan kommer du tillbaka.
Code:
javascript:(async()=>{
let U=(location.href.match(/(https:\/\/www\.rollspel\.nu\/threads\/[^\/]+\/)/)||[])[1];
if(!U)return alert("Kunde inte hitta trådens URL.");
let R=/\bröst\s*:\s*/i;
let $=s=>document.querySelector(s),
$$=s=>[...document.querySelectorAll(s)],
F=t=>t?new Date(t).toLocaleString("sv-SE",{dateStyle:"short",timeStyle:"short"}):"–",
E=s=>String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");
document.title="Varulv Rösträknare";
document.head.innerHTML=
'<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">'+
'<style>'+
'body{font-family:Arial,sans-serif;margin:2em;background:#f9f9f9;color:#333}'+
'label,button,select,input{margin:8px 10px 8px 0}select,button,input{padding:6px}#timeSlider{padding:0;}'+
'table{border-collapse:collapse;width:100%;margin-top:12px;background:#fff;border:1px solid #ccc}'+
'th,td{padding:8px 10px;border:1px solid #ccc;text-align:center;vertical-align:top}'+
'th{background:#3c8dbc;color:#fff;cursor:pointer}.summary{font-weight:bold;font-size:18px;margin:12px 0}'+
'.sliderRow{display:flex;align-items:center;gap:10px;margin:8px 0}.votesHeader{display:flex;align-items:baseline;gap:10px;margin-top:18px}'+
'.playerLabel{font-size:12px;opacity:.75}#playerFilter{font-size:12px;padding:4px}'+
'#voteTable td:last-child,#voteTable th:last-child{padding-right:18px}.small{font-size:12px;opacity:.8}'+
'#chart{max-width:520px;width:100%;height:auto}'+
'</style>';
document.body.innerHTML=
'<div id=pageTitle><h1>🐺 Varulv Rösträknare</h1></div>'+
'<div class=small>Källa: <a target=_blank href="'+U+'">'+U+'</a></div>'+
'<button id=exportBtn>Exportera CSV</button><br>'+
'<label><input type=radio name=voteView value=latest checked> Endast senaste röst</label>'+
'<label><input type=radio name=voteView value=all> Alla röster</label><br>'+
'<button id=liveModeBtn type=button>▶ Animera röster</button>'+
'<label>Hastighet: <input type=number id=liveDelayInput value=200 min=0 style="width:60px"> ms</label><br>'+
'<div class=sliderRow><label for=timeSlider>Visa röster fram till:</label>'+
'<input type=range id=timeSlider min=0 max=0 value=0 list=sliderTicks>'+
'<datalist id=sliderTicks></datalist> <span id=sliderTimeLabel>–</span></div>'+
'<div class=summary id=summary></div>'+
'<canvas id=chart width=520 height=225></canvas>'+
'<div class=votesHeader><h2 style="margin:0">Röster</h2>'+
'<label class=playerLabel for=playerFilter>Filter per spelare</label>'+
'<select id=playerFilter></select></div>'+
'<table id=voteTable><thead><tr>'+
'<th data-sort=from>Röstgivare</th><th>Röst</th><th data-sort=ts>Tidpunkt</th>'+
'<th>Riskerar att åka ut</th><th>Därefter</th>'+
'</tr></thead><tbody></tbody></table>';
let S={
votes:[],
players:[],
colors:{},
fp:"",
sort:"",
anim:0,
lim:null,
timeline:[],
sliderIndex:null,
animTimer:null
};
let L={
exp:$("#exportBtn"),
view:$$('input[name="voteView"]'),
live:$("#liveModeBtn"),
delay:$("#liveDelayInput"),
slider:$("#timeSlider"),
ticks:$("#sliderTicks"),
lbl:$("#sliderTimeLabel"),
sum:$("#summary"),
tb:$("#voteTable tbody"),
fp:$("#playerFilter"),
ths:$$('#voteTable thead th'),
cv:$("#chart")
};
let V=()=>L.view.find(r=>r.checked)?.value||"latest";
let C=n=>{
let u=[...new Set(n)].sort((a,b)=>a.localeCompare(b,"sv")),m={};
u.forEach((x,i)=>m[x]=`hsl(${Math.round(i*360/u.length)},70%,60%)`);
return m;
};
let Latest=vs=>{
let m={};
vs.slice().sort((a,b)=>+new Date(a.ts)-+new Date(b.ts)).forEach(v=>m[v.from]=v);
return Object.values(m);
};
const firstAfter=function(line){
const d=document.createElement("div");
d.innerHTML=line;
const w=document.createTreeWalker(d,NodeFilter.SHOW_TEXT|NodeFilter.SHOW_ELEMENT);
let seen=false,buf="";
while(w.nextNode()){
const n=w.currentNode;
if(n.nodeType===3){
buf+=(n.nodeValue||"");
if(!seen&&R.test(buf))seen=true;
if(buf.length>200)buf=buf.slice(-200);
}else if(seen&&n.nodeType===1){
const du=n.getAttribute&&n.getAttribute("data-username");
if(du){
const m=du.match(/^@(.+)$/);
if(m)return m[1].trim();
}
}
}
return null;
};
function PV(doc){
let out=[];
doc.querySelectorAll("article[data-author]").forEach(p=>{
let from=p.getAttribute("data-author")||"",
pid=(p.id||"").replace("js-post-",""),
ts=p.querySelector("time.u-dt")?.getAttribute("datetime")||"";
p.querySelectorAll("blockquote").forEach(b=>b.remove());
let html=p.querySelector(".message-content")?.innerHTML||"";
if(!html||!pid)return;
html.split(/\n|<br\s*\/?>/i).forEach(line=>{
const plain=line.replace(/<[^>]+>/g," ");
if(!R.test(plain))return;
const to=firstAfter(line);
if(to)out.push({from,to,ts,post:pid});
});
});
return out;
}
async function FA(){
L.sum.textContent="Hämtar tråden…";
let votes=[];
for(let page=1;;page++){
let url=page===1?U:`${U}page-${page}`,r=await fetch(url);
if(!r.ok)break;
let html=await r.text(),doc=new DOMParser().parseFromString(html,"text/html");
votes.push(...PV(doc));
if(!doc.querySelector(".pageNav-jump--next"))break;
}
votes.sort((a,b)=>+new Date(a.ts)-+new Date(b.ts));
return votes;
}
function rebuildSlider(){
S.timeline=S.votes
.filter(v=>v.ts&&!isNaN(+new Date(v.ts)))
.slice()
.sort((a,b)=>+new Date(a.ts)-+new Date(b.ts))
.map(v=>new Date(v.ts));
L.ticks.innerHTML="";
if(!S.timeline.length){
S.lim=null;
S.sliderIndex=0;
L.slider.min="0";
L.slider.max="0";
L.slider.value="0";
L.lbl.textContent="–";
Render();
return;
}
let max=S.timeline.length-1;
L.slider.min="0";
L.slider.max=""+max;
S.timeline.forEach((_,i)=>{
let o=document.createElement("option");
o.value=""+i;
L.ticks.appendChild(o);
});
if(S.sliderIndex==null||S.sliderIndex>max)S.sliderIndex=max;
if(S.sliderIndex<0)S.sliderIndex=0;
L.slider.value=""+S.sliderIndex;
S.lim=S.timeline[S.sliderIndex];
L.lbl.textContent=`${S.sliderIndex+1}/${S.timeline.length} röster (${F(S.lim)})`;
Render();
}
function onSlider(){
if(S.animTimer){
clearTimeout(S.animTimer);
S.animTimer=null;
}
S.sliderIndex=parseInt(L.slider.value||"0",10)||0;
if(!S.timeline.length){
S.lim=null;
L.lbl.textContent="–";
Render();
return;
}
let max=S.timeline.length-1;
if(S.sliderIndex<0)S.sliderIndex=0;
if(S.sliderIndex>max)S.sliderIndex=max;
S.lim=S.timeline[S.sliderIndex];
L.lbl.textContent=`${S.sliderIndex+1}/${S.timeline.length} röster (${F(S.lim)})`;
if(!S.anim)Render();
}
function Sub(){
let vs=S.votes;
if(S.lim)vs=vs.filter(v=>+new Date(v.ts)<=+S.lim);
if(V()==="latest")vs=Latest(vs);
if(S.fp)vs=vs.filter(v=>v.from===S.fp);
return vs;
}
function Bars(ent){
let ctx=L.cv.getContext("2d"),W=L.cv.width,H=L.cv.height;
ctx.clearRect(0,0,W,H);
let pad=10,left=160,
lab=ent.map(e=>e[0]),
dat=ent.map(e=>e[1]),
mx=Math.max(1,...dat);
ctx.font="18px Arial";
lab.forEach((name,i)=>{
let barH=Math.max(12,Math.floor((H-2*pad)/Math.max(1,lab.length))-2),
y=pad+i*(barH+2),
w=Math.floor((W-left-pad-10)*dat[i]/mx),
c=S.colors[name]||"#999",
val=""+dat[i],
tw=ctx.measureText(val).width;
ctx.fillStyle=c;
ctx.fillText(name,pad,y+barH-2);
ctx.fillStyle=c;
ctx.fillRect(left,y,w,barH);
ctx.fillStyle="#fff";
ctx.fillText(val,Math.max(left+4,left+w-tw-4),y+barH-2);
});
}
function SortApply(){
if(!S.sort)return;
let rows=$$("#voteTable tbody tr");
let asc=!S.sort.endsWith("-desc");
let k=S.sort.split("-")[0];
rows.sort((a,b)=>{
if(k==="from"){
let A=a.children[0].textContent.trim(),
B=b.children[0].textContent.trim();
return asc?A.localeCompare(B,"sv"):B.localeCompare(A,"sv");
}
let A=a.dataset.ts||"",
B=b.dataset.ts||"";
return asc?A.localeCompare(B):B.localeCompare(A);
});
L.tb.innerHTML="";
rows.forEach(r=>L.tb.appendChild(r));
}
function Empty(msg){
L.sum.textContent=msg||"";
L.tb.innerHTML="";
L.cv.getContext("2d").clearRect(0,0,L.cv.width,L.cv.height);
}
function Render(vsOverride=null){
let tableVotes=vsOverride??Sub();
let chartVotes;
if(vsOverride){
chartVotes=Latest(vsOverride);
}else{
chartVotes=S.votes;
if(S.lim)chartVotes=chartVotes.filter(v=>+new Date(v.ts)<=+S.lim);
chartVotes=Latest(chartVotes);
}
if(!chartVotes.length){
return Empty("Inga röster att visa.");
}
let cnt={},first={};
chartVotes.slice().sort((a,b)=>+new Date(a.ts)-+new Date(b.ts)).forEach(v=>{
cnt[v.to]=(cnt[v.to]||0)+1;
if(!first[v.to]||+new Date(v.ts)<+new Date(first[v.to]))first[v.to]=v.ts;
});
let ord=Object.entries(cnt).sort((a,b)=>b[1]-a[1]||(+new Date(first[a[0]])-+new Date(first[b[0]])));
let danger=(ord[0]||["Ingen",0])[0];
let dCnt=(ord[0]||["Ingen",0])[1];
let last=tableVotes.length
? tableVotes.reduce((acc,v)=>!acc||+new Date(v.ts)>+new Date(acc)?v.ts:acc,null)
: null;
L.sum.textContent=`⚠️ Risk för utröstning: ${danger} (${dCnt} röster, sedan ${F(first[danger])}). Senast röst lagd ${F(last)}.`;
L.tb.innerHTML="";
let hist={},run={},GC=n=>S.colors[n]||"#000";
tableVotes.slice().sort((a,b)=>+new Date(a.ts)-+new Date(b.ts)).forEach(v=>{
run[v.to]=(run[v.to]||0)+1;
let stand=Object.entries(run).sort((x,y)=>y[1]-x[1]);
let leader=stand[0]?`${stand[0][0]} (${stand[0][1]})`:"–";
let runner=stand[1]?`${stand[1][0]} (${stand[1][1]})`:"–";
hist[v.from]=hist[v.from]||[];
if(hist[v.from][hist[v.from].length-1]!==v.to)hist[v.from].push(v.to);
let chain=hist[v.from].map((n,i,a)=>{
let c=GC(n),safe=E(n);
return i===a.length-1
? `<a target=_blank href="${U}post-${v.post}" style="color:${c};font-weight:bold">${safe}</a>`
: `<span style="color:${c}">${safe}</span>`;
}).join(" → ");
let tr=document.createElement("tr");
tr.dataset.from=v.from;
tr.dataset.ts=v.ts||"";
tr.innerHTML=
`<td style="color:${GC(v.from)};font-weight:bold">${E(v.from)}</td>`+
`<td>${chain}</td>`+
`<td>${F(v.ts)}</td>`+
`<td>${leader}</td>`+
`<td>${runner}</td>`;
L.tb.appendChild(tr);
});
SortApply();
Bars(ord);
}
function Play(){
if(S.anim)return;
S.anim=1;
L.live.disabled=true;
let d=parseInt(L.delay.value||"200",10);
let lim=S.lim;
let all=S.votes
.filter(v=>!lim||+new Date(v.ts)<=+lim)
.sort((a,b)=>+new Date(a.ts)-+new Date(b.ts));
let i=0;
(function step(){
if(i>all.length){
S.anim=0;
L.live.disabled=false;
S.animTimer=null;
Render();
return;
}
let sub=all.slice(0,i);
let show=V()==="all"?sub:Latest(sub);
if(S.fp)show=show.filter(v=>v.from===S.fp);
Render(show);
i++;
S.animTimer=setTimeout(step,d);
})();
}
function CSV(){
let rows=Sub();
let csv=["Röstgivare,Röst,Tidpunkt,Post"];
rows.forEach(v=>csv.push(`"${v.from}","${v.to}","${v.ts}","${v.post}"`));
let a=document.createElement("a");
a.href=URL.createObjectURL(new Blob([csv.join("\n")],{type:"text/csv"}));
a.download="rostdata.csv";
a.click();
}
L.exp.addEventListener("click",CSV);
L.view.forEach(r=>r.addEventListener("change",()=>{
if(S.animTimer){
clearTimeout(S.animTimer);
S.animTimer=null;
}
S.sliderIndex=null;
rebuildSlider();
}));
L.live.addEventListener("click",Play);
L.slider.addEventListener("input",onSlider);
L.fp.addEventListener("change",()=>{
if(S.animTimer){
clearTimeout(S.animTimer);
S.animTimer=null;
}
S.fp=L.fp.value||"";
S.sliderIndex=null;
rebuildSlider();
});
L.ths.forEach(th=>{
let k=th.dataset.sort;
if(!k)return;
th.addEventListener("click",()=>{
let cur=S.sort||`${k}-asc`;
let desc=cur.startsWith(k)&&cur.endsWith("-asc");
S.sort=`${k}-${desc?"desc":"asc"}`;
Render();
});
});
S.votes=await FA();
S.players=[...new Set(S.votes.flatMap(v=>[v.from,v.to]))];
S.colors=C(S.players);
L.fp.innerHTML='<option value="">Alla</option>';
S.players.slice().sort((a,b)=>a.localeCompare(b,"sv")).forEach(n=>{
let o=document.createElement("option");
o.value=n;
o.textContent=n;
o.style.color=S.colors[n]||"#000";
o.style.fontWeight="bold";
L.fp.appendChild(o);
});
S.sliderIndex=null;
rebuildSlider();
})();
För ett arkiv som har alla gamla spel i sig (uppdaterar det med jämna mellanrum) så finns: https://damianoognissanti.github.io/varulv-rostraknare/
Last edited:
