fix(graph): resolution des bugs du graphe globale, support RTL, dates localisees et simulation D3 ultra-stable
This commit is contained in:
@@ -127,7 +127,7 @@ export async function GET(request: NextRequest) {
|
|||||||
const title = (noteA.title ?? '').trim().toLowerCase()
|
const title = (noteA.title ?? '').trim().toLowerCase()
|
||||||
if (title.length < 3) continue
|
if (title.length < 3) continue
|
||||||
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
const re = new RegExp(`\\b${escaped}\\b`, 'i')
|
const re = new RegExp(`(?<!\\p{L})${escaped}(?!\\p{L})`, 'ui')
|
||||||
for (const noteB of notes) {
|
for (const noteB of notes) {
|
||||||
if (noteA.id === noteB.id) continue
|
if (noteA.id === noteB.id) continue
|
||||||
if (re.test(stripHtml(noteB.content))) upsertEdge(noteA.id, noteB.id, 1.0, 'title_mention')
|
if (re.test(stripHtml(noteB.content))) upsertEdge(noteA.id, noteB.id, 1.0, 'title_mention')
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export function NoteGraphView({ embedded = false }: { embedded?: boolean }) {
|
|||||||
const { notebooks } = useNotebooks()
|
const { notebooks } = useNotebooks()
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const graphRef = useRef<any>(null)
|
const graphRef = useRef<any>(null)
|
||||||
|
const existingNodesRef = useRef<Map<string, any>>(new Map())
|
||||||
const [dimensions, setDimensions] = useState({ width: 800, height: 600 })
|
const [dimensions, setDimensions] = useState({ width: 800, height: 600 })
|
||||||
const [rawData, setRawData] = useState<RawData | null>(null)
|
const [rawData, setRawData] = useState<RawData | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -81,7 +82,7 @@ export function NoteGraphView({ embedded = false }: { embedded?: boolean }) {
|
|||||||
const [focusNodeId, setFocusNodeId] = useState<string | null>(null)
|
const [focusNodeId, setFocusNodeId] = useState<string | null>(null)
|
||||||
const [controlsOpen, setControlsOpen] = useState(!embedded)
|
const [controlsOpen, setControlsOpen] = useState(!embedded)
|
||||||
|
|
||||||
const { t } = useLanguage()
|
const { t, language } = useLanguage()
|
||||||
|
|
||||||
const plainText = useCallback((html: string | null | undefined) =>
|
const plainText = useCallback((html: string | null | undefined) =>
|
||||||
(html ?? '')
|
(html ?? '')
|
||||||
@@ -114,6 +115,19 @@ export function NoteGraphView({ embedded = false }: { embedded?: boolean }) {
|
|||||||
return plainText(notePreview.content).length
|
return plainText(notePreview.content).length
|
||||||
}, [notePreview, plainText])
|
}, [notePreview, plainText])
|
||||||
|
|
||||||
|
const isRtl = useMemo(() => {
|
||||||
|
if (!notePreview?.content) return false
|
||||||
|
const sample = plainText(notePreview.content).replace(/\s+/g, '').slice(0, 400)
|
||||||
|
const rtlChars = /[\u0590-\u05FF\u0600-\u06FF\u0700-\u074F\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/
|
||||||
|
let rtl = 0
|
||||||
|
let ltr = 0
|
||||||
|
for (const ch of sample) {
|
||||||
|
if (rtlChars.test(ch)) rtl++
|
||||||
|
else if (/[A-Za-z]/.test(ch)) ltr++
|
||||||
|
}
|
||||||
|
return rtl > 0 && rtl >= ltr
|
||||||
|
}, [notePreview, plainText])
|
||||||
|
|
||||||
// ─── Resize ───────────────────────────────────────────────────────────────
|
// ─── Resize ───────────────────────────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = containerRef.current
|
const el = containerRef.current
|
||||||
@@ -210,14 +224,27 @@ export function NoteGraphView({ embedded = false }: { embedded?: boolean }) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
nodes: filtered.map(n => ({
|
nodes: filtered.map(n => {
|
||||||
id: n.id,
|
const existing = existingNodesRef.current.get(n.id)
|
||||||
name: n.title,
|
if (existing) {
|
||||||
val: 1 + Math.min(n.degree, 8) * 0.5,
|
existing.name = n.title
|
||||||
color: colorMap.get(n.notebookId) ?? '#94a3b8',
|
existing.val = 1 + Math.min(n.degree, 8) * 0.5
|
||||||
notebookId: n.notebookId,
|
existing.color = colorMap.get(n.notebookId) ?? '#94a3b8'
|
||||||
degree: n.degree,
|
existing.notebookId = n.notebookId
|
||||||
})),
|
existing.degree = n.degree
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
const newNode = {
|
||||||
|
id: n.id,
|
||||||
|
name: n.title,
|
||||||
|
val: 1 + Math.min(n.degree, 8) * 0.5,
|
||||||
|
color: colorMap.get(n.notebookId) ?? '#94a3b8',
|
||||||
|
notebookId: n.notebookId,
|
||||||
|
degree: n.degree,
|
||||||
|
}
|
||||||
|
existingNodesRef.current.set(n.id, newNode)
|
||||||
|
return newNode
|
||||||
|
}),
|
||||||
links: visibleEdges.map(e => {
|
links: visibleEdges.map(e => {
|
||||||
let color = '#cbd5e1'
|
let color = '#cbd5e1'
|
||||||
let width = 2.5
|
let width = 2.5
|
||||||
@@ -420,24 +447,25 @@ export function NoteGraphView({ embedded = false }: { embedded?: boolean }) {
|
|||||||
onRenderFramePre={paintClusters}
|
onRenderFramePre={paintClusters}
|
||||||
nodeCanvasObjectMode={() => 'after'}
|
nodeCanvasObjectMode={() => 'after'}
|
||||||
nodeCanvasObject={(node: any, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
nodeCanvasObject={(node: any, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||||
|
const n = node as any
|
||||||
if (globalScale < 0.7) return
|
if (globalScale < 0.7) return
|
||||||
const name: string = node.name ?? ''
|
const name: string = n.name ?? ''
|
||||||
const label = name.length > 20 ? name.slice(0, 18) + '…' : name
|
const label = name.length > 20 ? name.slice(0, 18) + '…' : name
|
||||||
const fontSize = 11 / globalScale
|
const fontSize = 11 / globalScale
|
||||||
if (fontSize > 18) return
|
if (fontSize > 18) return
|
||||||
ctx.font = `${fontSize}px -apple-system, sans-serif`
|
ctx.font = `${fontSize}px -apple-system, sans-serif`
|
||||||
ctx.textAlign = 'center'
|
ctx.textAlign = 'center'
|
||||||
ctx.textBaseline = 'top'
|
ctx.textBaseline = 'top'
|
||||||
const r = Math.sqrt(node.val ?? 1) * 5
|
const r = Math.sqrt(n.val ?? 1) * 5
|
||||||
// White background behind label
|
// White background behind label
|
||||||
const tw = ctx.measureText(label).width
|
const tw = ctx.measureText(label).width
|
||||||
const lx = node.x - tw / 2 - 2
|
const lx = n.x - tw / 2 - 2
|
||||||
const ly = node.y + r + 2
|
const ly = n.y + r + 2
|
||||||
ctx.fillStyle = 'rgba(250,250,249,0.85)'
|
ctx.fillStyle = 'rgba(250,250,249,0.85)'
|
||||||
ctx.fillRect(lx, ly, tw + 4, fontSize + 2)
|
ctx.fillRect(lx, ly, tw + 4, fontSize + 2)
|
||||||
// Label text
|
// Label text
|
||||||
ctx.fillStyle = '#334155'
|
ctx.fillStyle = '#334155'
|
||||||
ctx.fillText(label, node.x, ly + 1)
|
ctx.fillText(label, n.x, ly + 1)
|
||||||
}}
|
}}
|
||||||
cooldownTicks={80}
|
cooldownTicks={80}
|
||||||
d3AlphaDecay={0.03}
|
d3AlphaDecay={0.03}
|
||||||
@@ -597,7 +625,10 @@ export function NoteGraphView({ embedded = false }: { embedded?: boolean }) {
|
|||||||
{selectedNotebookName}
|
{selectedNotebookName}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<h2 className="text-sm font-semibold text-slate-800 dark:text-slate-100 leading-snug tracking-tight select-all">
|
<h2
|
||||||
|
dir={isRtl ? 'rtl' : 'ltr'}
|
||||||
|
className={`text-sm font-semibold text-slate-800 dark:text-slate-100 leading-snug tracking-tight select-all ${isRtl ? 'text-right font-persian font-semibold' : 'text-left font-sans'}`}
|
||||||
|
>
|
||||||
{selectedNode.title || <span className="italic text-concrete/40">{t('notes.untitled')}</span>}
|
{selectedNode.title || <span className="italic text-concrete/40">{t('notes.untitled')}</span>}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -613,8 +644,8 @@ export function NoteGraphView({ embedded = false }: { embedded?: boolean }) {
|
|||||||
<div className="px-5 py-3.5 bg-slate-50/50 dark:bg-stone-950/20 border-b border-border/30 grid grid-cols-2 gap-y-2 gap-x-4 text-[10px] text-concrete/60 select-none">
|
<div className="px-5 py-3.5 bg-slate-50/50 dark:bg-stone-950/20 border-b border-border/30 grid grid-cols-2 gap-y-2 gap-x-4 text-[10px] text-concrete/60 select-none">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Calendar size={11} className="text-concrete/40 shrink-0" />
|
<Calendar size={11} className="text-concrete/40 shrink-0" />
|
||||||
<span className="truncate" title={new Date(selectedNode.createdAt).toLocaleString()}>
|
<span className="truncate" title={new Date(selectedNode.createdAt).toLocaleString(language === 'fa' ? 'fa-IR' : language)}>
|
||||||
{new Date(selectedNode.createdAt).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric' })}
|
{new Date(selectedNode.createdAt).toLocaleDateString(language === 'fa' ? 'fa-IR' : language, { day: '2-digit', month: 'short', year: 'numeric' })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
@@ -634,7 +665,7 @@ export function NoteGraphView({ embedded = false }: { embedded?: boolean }) {
|
|||||||
<Clock size={11} className="text-concrete/40 shrink-0" />
|
<Clock size={11} className="text-concrete/40 shrink-0" />
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{t('graphView.preview.updated')}{' '}
|
{t('graphView.preview.updated')}{' '}
|
||||||
{new Date(notePreview.updatedAt).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' })}
|
{new Date(notePreview.updatedAt).toLocaleDateString(language === 'fa' ? 'fa-IR' : language, { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -667,22 +698,23 @@ export function NoteGraphView({ embedded = false }: { embedded?: boolean }) {
|
|||||||
<>
|
<>
|
||||||
{/* Note Content Renderer */}
|
{/* Note Content Renderer */}
|
||||||
{notePreview.type === 'checklist' && notePreview.checkItems && notePreview.checkItems.length > 0 ? (
|
{notePreview.type === 'checklist' && notePreview.checkItems && notePreview.checkItems.length > 0 ? (
|
||||||
<div className="space-y-2 select-none">
|
<div className="space-y-2 select-none" dir={isRtl ? 'rtl' : 'ltr'}>
|
||||||
<NoteChecklist
|
<NoteChecklist
|
||||||
items={notePreview.checkItems}
|
items={notePreview.checkItems}
|
||||||
onToggleItem={() => {}}
|
onToggleItem={() => {}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : notePreview.type === 'markdown' || notePreview.isMarkdown ? (
|
) : notePreview.type === 'markdown' || notePreview.isMarkdown ? (
|
||||||
<div className="text-xs text-slate-600 dark:text-stone-300">
|
<div className="text-xs text-slate-600 dark:text-stone-300" dir={isRtl ? 'rtl' : 'ltr'}>
|
||||||
<MarkdownContent
|
<MarkdownContent
|
||||||
content={notePreview.content}
|
content={notePreview.content}
|
||||||
className="prose-h1:text-sm prose-h1:font-bold prose-h1:text-slate-800 dark:prose-h1:text-stone-100 prose-h1:mt-3 prose-h1:mb-1 prose-h2:text-xs prose-h2:font-bold prose-h2:text-slate-700 dark:prose-h2:text-stone-200 prose-h2:mt-2 prose-h2:mb-1 prose-p:text-xs prose-p:leading-relaxed prose-p:mb-2 prose-ul:list-disc prose-ul:pl-4 prose-ol:list-decimal prose-ol:pl-4"
|
className={`prose-h1:text-sm prose-h1:font-bold prose-h1:text-slate-800 dark:prose-h1:text-stone-100 prose-h1:mt-3 prose-h1:mb-1 prose-h2:text-xs prose-h2:font-bold prose-h2:text-slate-700 dark:prose-h2:text-stone-200 prose-h2:mt-2 prose-h2:mb-1 prose-p:text-xs prose-p:leading-relaxed prose-p:mb-2 prose-ul:list-disc prose-ul:pl-4 prose-ol:list-decimal prose-ol:pl-4 ${isRtl ? 'text-right font-persian' : 'text-left font-sans'}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="text-xs text-slate-600 dark:text-stone-300 space-y-2 leading-relaxed break-words
|
dir={isRtl ? 'rtl' : 'ltr'}
|
||||||
|
className={`text-xs text-slate-600 dark:text-stone-300 space-y-2 leading-relaxed break-words ${isRtl ? 'text-right font-persian' : 'text-left font-sans'}
|
||||||
[&_h1]:text-sm [&_h1]:font-bold [&_h1]:text-slate-800 dark:[&_h1]:text-stone-100 [&_h1]:mt-4 [&_h1]:mb-1.5 [&_h1]:first:mt-0
|
[&_h1]:text-sm [&_h1]:font-bold [&_h1]:text-slate-800 dark:[&_h1]:text-stone-100 [&_h1]:mt-4 [&_h1]:mb-1.5 [&_h1]:first:mt-0
|
||||||
[&_h2]:text-xs [&_h2]:font-bold [&_h2]:text-slate-700 dark:[&_h2]:text-stone-200 [&_h2]:mt-3 [&_h2]:mb-1 [&_h2]:first:mt-0
|
[&_h2]:text-xs [&_h2]:font-bold [&_h2]:text-slate-700 dark:[&_h2]:text-stone-200 [&_h2]:mt-3 [&_h2]:mb-1 [&_h2]:first:mt-0
|
||||||
[&_h3]:text-xs [&_h3]:font-semibold [&_h3]:text-slate-600 dark:[&_h3]:text-stone-300 [&_h3]:mt-2 [&_h3]:mb-1 [&_h3]:first:mt-0
|
[&_h3]:text-xs [&_h3]:font-semibold [&_h3]:text-slate-600 dark:[&_h3]:text-stone-300 [&_h3]:mt-2 [&_h3]:mb-1 [&_h3]:first:mt-0
|
||||||
@@ -695,7 +727,7 @@ export function NoteGraphView({ embedded = false }: { embedded?: boolean }) {
|
|||||||
[&_code]:px-1 [&_code]:py-0.5 [&_code]:bg-slate-100 dark:[&_code]:bg-stone-850 [&_code]:rounded [&_code]:font-mono [&_code]:text-[10px]
|
[&_code]:px-1 [&_code]:py-0.5 [&_code]:bg-slate-100 dark:[&_code]:bg-stone-850 [&_code]:rounded [&_code]:font-mono [&_code]:text-[10px]
|
||||||
[&_pre]:p-2.5 [&_pre]:bg-slate-900 [&_pre]:text-slate-100 [&_pre]:rounded-lg [&_pre]:overflow-x-auto [&_pre]:font-mono [&_pre]:text-[10px] [&_pre]:my-2
|
[&_pre]:p-2.5 [&_pre]:bg-slate-900 [&_pre]:text-slate-100 [&_pre]:rounded-lg [&_pre]:overflow-x-auto [&_pre]:font-mono [&_pre]:text-[10px] [&_pre]:my-2
|
||||||
[&_blockquote]:border-l-2 [&_blockquote]:border-slate-300 dark:[&_blockquote]:border-stone-700 [&_blockquote]:pl-3 [&_blockquote]:italic [&_blockquote]:text-slate-500 [&_blockquote]:my-2
|
[&_blockquote]:border-l-2 [&_blockquote]:border-slate-300 dark:[&_blockquote]:border-stone-700 [&_blockquote]:pl-3 [&_blockquote]:italic [&_blockquote]:text-slate-500 [&_blockquote]:my-2
|
||||||
[&_a]:text-indigo-600 dark:[&_a]:text-indigo-400 [&_a]:underline [&_a]:hover:text-indigo-500"
|
[&_a]:text-indigo-600 dark:[&_a]:text-indigo-400 [&_a]:underline [&_a]:hover:text-indigo-500`}
|
||||||
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user