add: relation graph

This commit is contained in:
windingwind 2024-04-07 22:31:27 +08:00
parent 59f9dd11eb
commit f30504e22f
15 changed files with 405 additions and 3 deletions

View File

@ -0,0 +1,6 @@
<svg t="1712475235724" class="icon" viewBox="0 0 1025 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6084"
width="16" height="16">
<path
d="M896.244622 765.400781c-32.538127 0-62.261685 12.153822-84.820885 32.154322l-212.542629-190.40988a222.180398 222.180398 0 0 0 39.74513-127.33794c0-35.267407-8.145193-69.340754-23.497389-100.045146l139.363827-114.416508a156.507113 156.507113 0 1 0-23.75326-32.154322l-136.890417 112.369548a226.658122 226.658122 0 0 0-20.725465-23.881194 222.308333 222.308333 0 0 0-158.212913-65.54535 222.095108 222.095108 0 0 0-133.819978 44.43608L218.658377 219.71549a127.807035 127.807035 0 1 0-32.580772 23.113584l65.246835 84.43708a222.180398 222.180398 0 0 0-60.044146 152.498484c0 58.850086 22.601845 114.331218 63.75426 156.379179L200.278387 710.729904a127.807035 127.807035 0 1 0 29.510333 27.292794l55.609067-75.780148a222.137753 222.137753 0 0 0 129.512834 41.237706c59.702986 0 115.909083-23.284165 158.170268-65.502705l0.127935-0.17058 212.798499 190.623105a127.807035 127.807035 0 1 0 110.194654-63.029295z m-28.742723-701.595374c51.429858 0 93.264593 41.79209 93.264593 93.221948s-41.79209 93.264593-93.264593 93.264593A93.307238 93.307238 0 0 1 774.279951 157.027355c0-51.429858 41.79209-93.221948 93.221948-93.221948zM65.520218 128.327276A63.967485 63.967485 0 0 1 129.359768 64.487726a63.967485 63.967485 0 0 1 63.92484 63.882195 63.967485 63.967485 0 0 1-63.92484 63.92484 63.967485 63.967485 0 0 1-63.83955-63.967485z m63.882195 752.641428A63.967485 63.967485 0 0 1 65.520218 817.043864a63.967485 63.967485 0 0 1 63.882195-63.882195 63.967485 63.967485 0 0 1 63.92484 63.882195 63.967485 63.967485 0 0 1-63.92484 63.92484z m285.593498-241.370643a159.961357 159.961357 0 0 1-159.790777-159.790778 159.961357 159.961357 0 0 1 159.790777-159.748132 159.961357 159.961357 0 0 1 159.748132 159.748132 159.961357 159.961357 0 0 1-159.748132 159.748133zM896.244622 957.047366a63.967485 63.967485 0 0 1-63.92484-63.882195 63.967485 63.967485 0 0 1 63.92484-63.92484 63.967485 63.967485 0 0 1 63.882195 63.92484 63.967485 63.967485 0 0 1-63.882195 63.882195z"
fill="#e8af59" p-id="6085"></path>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -0,0 +1,6 @@
<svg t="1712475235724" class="icon" viewBox="0 0 1025 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6084"
width="20" height="20">
<path
d="M896.244622 765.400781c-32.538127 0-62.261685 12.153822-84.820885 32.154322l-212.542629-190.40988a222.180398 222.180398 0 0 0 39.74513-127.33794c0-35.267407-8.145193-69.340754-23.497389-100.045146l139.363827-114.416508a156.507113 156.507113 0 1 0-23.75326-32.154322l-136.890417 112.369548a226.658122 226.658122 0 0 0-20.725465-23.881194 222.308333 222.308333 0 0 0-158.212913-65.54535 222.095108 222.095108 0 0 0-133.819978 44.43608L218.658377 219.71549a127.807035 127.807035 0 1 0-32.580772 23.113584l65.246835 84.43708a222.180398 222.180398 0 0 0-60.044146 152.498484c0 58.850086 22.601845 114.331218 63.75426 156.379179L200.278387 710.729904a127.807035 127.807035 0 1 0 29.510333 27.292794l55.609067-75.780148a222.137753 222.137753 0 0 0 129.512834 41.237706c59.702986 0 115.909083-23.284165 158.170268-65.502705l0.127935-0.17058 212.798499 190.623105a127.807035 127.807035 0 1 0 110.194654-63.029295z m-28.742723-701.595374c51.429858 0 93.264593 41.79209 93.264593 93.221948s-41.79209 93.264593-93.264593 93.264593A93.307238 93.307238 0 0 1 774.279951 157.027355c0-51.429858 41.79209-93.221948 93.221948-93.221948zM65.520218 128.327276A63.967485 63.967485 0 0 1 129.359768 64.487726a63.967485 63.967485 0 0 1 63.92484 63.882195 63.967485 63.967485 0 0 1-63.92484 63.92484 63.967485 63.967485 0 0 1-63.83955-63.967485z m63.882195 752.641428A63.967485 63.967485 0 0 1 65.520218 817.043864a63.967485 63.967485 0 0 1 63.882195-63.882195 63.967485 63.967485 0 0 1 63.92484 63.882195 63.967485 63.967485 0 0 1-63.92484 63.92484z m285.593498-241.370643a159.961357 159.961357 0 0 1-159.790777-159.790778 159.961357 159.961357 0 0 1 159.790777-159.748132 159.961357 159.961357 0 0 1 159.748132 159.748132 159.961357 159.961357 0 0 1-159.748132 159.748133zM896.244622 957.047366a63.967485 63.967485 0 0 1-63.92484-63.882195 63.967485 63.967485 0 0 1 63.92484-63.92484 63.967485 63.967485 0 0 1 63.882195 63.92484 63.967485 63.967485 0 0 1-63.882195 63.882195z"
fill="#e8af59" p-id="6085"></path>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,210 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Force-Directed Tree</title>
<script src="chrome://__addonRef__/content/lib/js/d3.v7.min.js"></script>
<link rel="stylesheet" href="chrome://zotero-platform/content/zotero.css" />
<style>
html,
body {
overflow: hidden;
background: var(--material-background);
}
.tooltip {
position: absolute;
left: 0;
top: 0;
margin: 10px;
}
.node:hover {
cursor: pointer;
}
@media (prefers-color-scheme: light) {
:root {
--text-color: black;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text-color: white;
}
}
</style>
<script>
window.addEventListener("message", (ev) => {
if (ev.data.type === "render") {
render(ev.data.graph);
}
});
function render(data) {
// Define the data for the nodes and links
// Specify the dimensions of the chart.
var width = window.innerWidth;
var height = window.innerHeight;
// 2: 100; 22: 50
function distance() {
if (data.nodes.length > 22) {
return 50;
}
return 105 - 2.5 * data.nodes.length;
}
// Specify the color scale.
const color = d3.scaleOrdinal([1, 2], ["grey", "#e8af59"]);
// The force simulation mutates links and nodes, so create a copy
// so that re-evaluating this cell produces the same result.
const links = data.links.map((d) => ({ ...d }));
const nodes = data.nodes.map((d) => ({ ...d }));
// Create a simulation with several forces.
const simulation = d3
.forceSimulation(nodes)
.force(
"link",
d3
.forceLink(links)
.id((d) => d.id)
.distance(distance()),
)
.force("charge", d3.forceManyBody().strength(-400))
.force("x", d3.forceX())
.force("y", d3.forceY());
// Create the SVG container.
const svg = d3
.create("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", [-width / 2, -height / 2, width, height])
.attr("style", "max-width: 100%; height: auto;");
// Add a line for each link, and a circle for each node.
const link = svg
.append("g")
.attr("stroke", "#999")
.attr("stroke-opacity", 0.6)
.selectAll("line")
.data(links)
.join("line")
.attr("stroke-width", (d) => Math.sqrt(d.value));
const node = svg
.append("g")
.attr("stroke", "#fff")
.attr("stroke-width", 1.5)
.selectAll("circle")
.data(nodes)
.join("g") // Append a 'g' element for each node
.attr("class", "node"); // Assign a class for styling if needed
node
.append("circle")
.attr("r", 7)
.attr("fill", (d) => color(d.group));
node
.append("text")
.attr("x", 0) // Center the text horizontally
.attr("y", 18) // Position the text below the circle
.attr("text-anchor", "middle") // Ensure the text is centered
.attr("fill", "var(--text-color)")
.attr("stroke", "none")
.text((d) => d.title);
node.append("title").text((d) => d.title);
// Add a drag behavior.
node.call(
d3
.drag()
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended),
);
// Set the position attributes of links and nodes each time the simulation ticks.
simulation.on("tick", () => {
link
.attr("x1", (d) => d.source.x)
.attr("y1", (d) => d.source.y)
.attr("x2", (d) => d.target.x)
.attr("y2", (d) => d.target.y);
node.attr("transform", (d) => `translate(${d.x},${d.y})`);
});
// Reheat the simulation when drag starts, and fix the subject position.
function dragstarted(event) {
if (!event.active) simulation.alphaTarget(0.3).restart();
event.subject.fx = event.subject.x;
event.subject.fy = event.subject.y;
}
// Update the subject (dragged node) position during drag.
function dragged(event) {
event.subject.fx = event.x;
event.subject.fy = event.y;
}
// Restore the target alpha so the simulation cools after dragging ends.
// Unfix the subject position now that its no longer being dragged.
function dragended(event) {
if (!event.active) simulation.alphaTarget(0);
event.subject.fx = null;
event.subject.fy = null;
}
// Add the hover interaction
node
.on("mouseover", function (event, d) {
// Enlarge the node circle
d3.select(this)
.select("circle")
.transition()
.duration(200)
.attr("r", 10); // New, larger radius
d3.select(".tooltip").style("display", "").html(d.title);
})
.on("mouseout", function (event, d) {
// Shrink the node circle back to original size
d3.select(this)
.select("circle")
.transition()
.duration(500)
.attr("r", 7); // Original radius
d3.select(".tooltip").style("display", "none");
})
.on("click", function (event, d) {
window.postMessage(
{ type: "openNote", isShift: !!event.shiftKey, id: d.id },
"*",
);
});
document.body.replaceChildren(
svg.node(),
d3
.create("div")
.attr("class", "tooltip")
.style("display", "none")
.node(),
);
}
d3.select(window).on("resize", function () {
width = window.innerWidth;
height = window.innerHeight;
d3.select(document.querySelector("svg"))
.attr("width", width)
.attr("height", height)
.attr("viewBox", [-width / 2, -height / 2, width, height]);
});
</script>
</head>
<body></body>
</html>

View File

@ -0,0 +1,5 @@
#bn-relation-graph {
width: 100%;
height: 250px;
border-radius: 8px;
}

View File

@ -0,0 +1,6 @@
note-relation-header =
.label = Relation Graph
note-relation-sidenav =
.tooltiptext = Relation Graph
note-relation-refresh =
.tooltiptext = Refresh

View File

@ -0,0 +1,6 @@
note-relation-header =
.label = Relation Graph
note-relation-sidenav =
.tooltiptext = Relation Graph
note-relation-refresh =
.tooltiptext = Refresh

View File

@ -0,0 +1,6 @@
note-relation-header =
.label = Relation Graph
note-relation-sidenav =
.tooltiptext = Relation Graph
note-relation-refresh =
.tooltiptext = Refresh

View File

@ -0,0 +1,6 @@
note-relation-header =
.label = Relation Graph
note-relation-sidenav =
.tooltiptext = Relation Graph
note-relation-refresh =
.tooltiptext = Refresh

View File

@ -0,0 +1,6 @@
note-relation-header =
.label = 关系图
note-relation-sidenav =
.tooltiptext = 关系图
note-relation-refresh =
.tooltiptext = 刷新

View File

@ -5,9 +5,9 @@ const buildDir = "build";
export async function main() {
await build({
entryPoints: ["./src/extras/*.ts"],
entryPoints: ["./src/extras/*.*"],
outdir: path.join(buildDir, "addon/chrome/content/scripts"),
bundle: true,
target: ["firefox102"],
target: ["firefox115"],
}).catch(() => exit(1));
}

View File

@ -194,7 +194,7 @@ export const esbuildOptions = {
__env__: `"${env.NODE_ENV}"`,
},
bundle: true,
target: "firefox102",
target: "firefox115",
outfile: path.join(
buildDir,
`addon/chrome/content/scripts/${config.addonRef}.js`,

View File

@ -23,6 +23,7 @@ export class NoteDetails extends ItemDetails {
init() {
MozXULElement.insertFTLIfNeeded(`${config.addonRef}-notePreview.ftl`);
MozXULElement.insertFTLIfNeeded(`${config.addonRef}-noteRelation.ftl`);
super.init();
}

View File

@ -37,6 +37,7 @@ import { initSyncList } from "./modules/sync/api";
import { patchViewItems } from "./modules/viewItems";
import { onUpdateRelated } from "./modules/relatedNotes";
import { getFocusedWindow } from "./utils/window";
import { registerNoteRelation } from "./modules/workspace/relation";
async function onStartup() {
await Promise.all([
@ -58,6 +59,8 @@ async function onStartup() {
registerReaderAnnotationButton();
registerNoteRelation();
initSyncList();
setSyncing();

View File

@ -0,0 +1,139 @@
import { config } from "../../../package.json";
import { slice } from "../../utils/str";
import { waitUtilAsync } from "../../utils/wait";
export function registerNoteRelation() {
const key = Zotero.ItemPaneManager.registerSection({
paneID: `bn-note-relation`,
pluginID: config.addonID,
header: {
icon: `chrome://${config.addonRef}/content/icons/relation-16.svg`,
l10nID: `${config.addonRef}-note-relation-header`,
},
sidenav: {
icon: `chrome://${config.addonRef}/content/icons/relation-20.svg`,
l10nID: `${config.addonRef}-note-relation-sidenav`,
},
bodyXHTML: `
<linkset>
<html:link
rel="stylesheet"
href="chrome://${config.addonRef}/content/styles/relation.css"
></html:link>
</linkset>
<iframe
src="chrome://${config.addonRef}/content/relationGraph.html"
id="bn-relation-graph"
></iframe>`,
sectionButtons: [
{
type: "refreshGraph",
icon: "chrome://zotero/skin/16/universal/sync.svg",
l10nID: `${config.addonRef}-note-relation-refresh`,
onClick: ({ body, item }) => {
refresh(body, item);
},
},
],
onInit({ body }) {
body
.querySelector("iframe")!
.contentWindow?.addEventListener("message", (ev) => {
if (ev.data.type === "openNote") {
addon.hooks.onOpenNote(
ev.data.id,
ev.data.isShift ? "window" : "tab",
);
}
});
},
onItemChange: ({ body, setEnabled }) => {
if (body.closest("bn-workspace") as HTMLElement | undefined) {
setEnabled(true);
return;
}
setEnabled(false);
},
onRender: () => {},
onAsyncRender: async ({ body, item }) => {
await refresh(body, item);
},
});
}
async function refresh(body: HTMLElement, item: Zotero.Item) {
const data = await getRelationData(item);
await waitUtilAsync(
() =>
body.querySelector("iframe")!.contentDocument?.readyState === "complete",
);
body.querySelector("iframe")!.contentWindow?.postMessage(
{
type: "render",
graph: data,
},
"*",
);
}
async function getRelationData(note: Zotero.Item) {
if (!note) return;
const currentContent = note.getNote();
const currentLink = addon.api.convert.note2link(note);
const currentTitle = slice(note.getNoteTitle(), 15);
const { detectedIDSet, currentIDSet } =
await addon.api.related.getRelatedNoteIds(note.id);
if (!areSetsEqual(detectedIDSet, currentIDSet)) {
await addon.api.related.updateRelatedNotes(note.id);
}
const items = Zotero.Items.get(Array.from(detectedIDSet));
const nodes = [];
const links = [];
for (const item of items) {
const compareContent = item.getNote();
const compareLink = addon.api.convert.note2link(item);
const compareTitle = slice(item.getNoteTitle(), 15);
if (currentLink && compareContent.includes(currentLink)) {
links.push({
source: item.id,
target: note.id,
value: 1,
});
}
if (compareLink && currentContent.includes(compareLink)) {
links.push({
source: note.id,
target: item.id,
value: 1,
});
}
nodes.push({
id: item.id,
title: compareTitle,
group: 2,
});
}
nodes.push({
id: note.id,
title: currentTitle,
group: 1,
});
return { nodes, links };
}
function areSetsEqual(set1: Set<any>, set2: Set<any>): boolean {
if (set1.size !== set2.size) {
return false;
}
for (const item of set1) {
if (!set2.has(item)) {
return false;
}
}
return true;
}