feat: ship v1.0.0 with character graph and writing analytics
Add P7 Cytoscape relationship graph with setting CRUD, P9 cockpit analytics driven by writing_sessions, chapter POV selection, and full test coverage. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { WritingAnalyticsSummary } from '@shared/types'
|
||||
import { HourlyHeatmap } from '@renderer/components/cockpit/HourlyHeatmap'
|
||||
import { useGraphStore } from '@renderer/stores/useGraphStore'
|
||||
|
||||
interface CockpitAnalyticsProps {
|
||||
analytics: WritingAnalyticsSummary | null
|
||||
}
|
||||
|
||||
export function CockpitAnalytics({ analytics }: CockpitAnalyticsProps): React.JSX.Element | null {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!analytics) return null
|
||||
|
||||
const speedLabel =
|
||||
analytics.todaySpeedWpm != null
|
||||
? t('analytics.speedToday', { wpm: analytics.todaySpeedWpm })
|
||||
: t('analytics.speedUnknown')
|
||||
|
||||
const povTop = analytics.povDistribution.slice(0, 5)
|
||||
const povMax = Math.max(...povTop.map((p) => p.words), 1)
|
||||
const volMax = Math.max(...analytics.volumeDistribution.map((v) => v.words), 1)
|
||||
const weekMax = Math.max(...analytics.weekTrend.map((d) => d.words), 1)
|
||||
|
||||
const povName = (characterId: string, name: string): string =>
|
||||
characterId === '__none__' ? t('analytics.povUnassigned') : name
|
||||
|
||||
return (
|
||||
<div className="cockpit-analytics" data-testid="cockpit-analytics">
|
||||
<h4>{t('analytics.title')}</h4>
|
||||
<div data-testid="cockpit-speed-today">{speedLabel}</div>
|
||||
<div>
|
||||
<div className="cockpit-analytics-label">{t('analytics.hourlyHeatmap')}</div>
|
||||
<HourlyHeatmap data={analytics.hourlyHeatmap} testId="cockpit-hourly-heatmap" />
|
||||
</div>
|
||||
<div data-testid="cockpit-pov-chart">
|
||||
<div className="cockpit-analytics-label">{t('analytics.povDistribution')}</div>
|
||||
{povTop.map((p) => (
|
||||
<div key={p.characterId} className="analytics-bar-row">
|
||||
<span>{povName(p.characterId, p.name)}</span>
|
||||
<div className="analytics-bar-track">
|
||||
<div className="pov-bar" style={{ width: `${(p.words / povMax) * 100}%` }} />
|
||||
</div>
|
||||
<span>{p.words}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div data-testid="cockpit-volume-chart">
|
||||
<div className="cockpit-analytics-label">{t('analytics.volumeDistribution')}</div>
|
||||
{analytics.volumeDistribution.map((v) => (
|
||||
<div key={v.volumeId} className="analytics-bar-row">
|
||||
<span>{v.title}</span>
|
||||
<div className="analytics-bar-track">
|
||||
<div className="volume-bar" style={{ width: `${(v.words / volMax) * 100}%` }} />
|
||||
</div>
|
||||
<span>{v.words}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div data-testid="cockpit-week-trend">
|
||||
<div className="cockpit-analytics-label">{t('analytics.weekTrend')}</div>
|
||||
<div className="week-trend-bars">
|
||||
{analytics.weekTrend.map((d) => (
|
||||
<div
|
||||
key={d.date}
|
||||
className="week-trend-bar"
|
||||
style={{ height: `${Math.max(4, (d.words / weekMax) * 48)}px` }}
|
||||
title={`${d.date}: ${d.words}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-testid="cockpit-open-graph"
|
||||
onClick={() => useGraphStore.getState().openModal()}
|
||||
>
|
||||
{t('cockpit.openGraph')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useEffect } from 'react'
|
||||
import type { WritingMilestone } from '@shared/types'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { WritingMilestone, WritingAnalyticsSummary } from '@shared/types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { useEditStore } from '@renderer/stores/useEditStore'
|
||||
import { useCockpitStore } from '@renderer/stores/useCockpitStore'
|
||||
import { WritingHeatmap } from '@renderer/components/cockpit/WritingHeatmap'
|
||||
import { CockpitAnalytics } from '@renderer/components/cockpit/CockpitAnalytics'
|
||||
import { useKnowledgeStore } from '@renderer/stores/useKnowledgeStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
|
||||
interface CockpitModalProps {
|
||||
onOpenBridge: (chapterId: string) => void
|
||||
@@ -28,10 +30,14 @@ export function CockpitModal({ onOpenBridge }: CockpitModalProps): React.JSX.Ele
|
||||
const switchTarget = useEditStore((s) => s.switchTarget)
|
||||
const { open, summary, loading, close, loadSummary } = useCockpitStore()
|
||||
const openForgottenFilter = useKnowledgeStore((s) => s.openForgottenFilter)
|
||||
const [analytics, setAnalytics] = useState<WritingAnalyticsSummary | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !bookId) return
|
||||
void loadSummary(bookId, activeVolumeId ?? undefined)
|
||||
void Promise.all([
|
||||
loadSummary(bookId, activeVolumeId ?? undefined),
|
||||
ipcCall(() => window.electronAPI.analytics.getSummary(bookId)).then(setAnalytics)
|
||||
])
|
||||
}, [open, bookId, activeVolumeId, loadSummary])
|
||||
|
||||
if (!open) return null
|
||||
@@ -117,6 +123,7 @@ export function CockpitModal({ onOpenBridge }: CockpitModalProps): React.JSX.Ele
|
||||
<div className="cockpit-pomodoro-today" data-testid="cockpit-pomodoro-today">
|
||||
{t('cockpit.pomodoroToday', { count: summary.pomodoroTodayCount ?? 0 })}
|
||||
</div>
|
||||
<CockpitAnalytics analytics={analytics} />
|
||||
<div className="cockpit-achievements-section">
|
||||
<div className="cockpit-heatmap-label">{t('cockpit.achievements')}</div>
|
||||
<div className="cockpit-achievements" data-testid="cockpit-achievements">
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
interface HourlyHeatmapProps {
|
||||
data: number[]
|
||||
testId?: string
|
||||
}
|
||||
|
||||
export function HourlyHeatmap({ data, testId }: HourlyHeatmapProps): React.JSX.Element {
|
||||
const max = Math.max(...data, 1)
|
||||
return (
|
||||
<div className="hourly-heatmap" data-testid={testId}>
|
||||
{data.map((value, hour) => (
|
||||
<div
|
||||
key={hour}
|
||||
className="hourly-heatmap-cell"
|
||||
title={`${hour}:00`}
|
||||
style={{
|
||||
opacity: value > 0 ? 0.35 + (value / max) * 0.65 : 0.2,
|
||||
background: value > 0 ? 'var(--accent)' : undefined
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { editorDirtyAtom, editorSavingAtom, lastSavedAtAtom, wordCountAtom } fro
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { flushEditSession, registerEditorFlush, useEditStore } from '@renderer/stores/useEditStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
import { SettingRelationshipsEditor } from '@renderer/components/setting/SettingRelationshipsEditor'
|
||||
import { LandmarkMark } from '@renderer/extensions/landmark-mark'
|
||||
import { SettingMention } from '@renderer/extensions/mention'
|
||||
import { registerHighlightToken, registerInsertLandmark, registerEditorInsert } from '@renderer/lib/editor-commands'
|
||||
@@ -347,6 +348,9 @@ export function TipTapEditor({ target, bookId }: TipTapEditorProps): React.JSX.E
|
||||
setMentionOpen(false)
|
||||
}
|
||||
|
||||
const settingEntry =
|
||||
target?.kind === 'setting' ? settings.find((s) => s.id === target.id) : null
|
||||
|
||||
if (!target) {
|
||||
return <div className="placeholder-box">{t('editor.placeholder')}</div>
|
||||
}
|
||||
@@ -354,6 +358,9 @@ export function TipTapEditor({ target, bookId }: TipTapEditorProps): React.JSX.E
|
||||
return (
|
||||
<div className="editor-content-wrap">
|
||||
<EditorContent editor={editor} />
|
||||
{settingEntry?.type === 'character' && bookId && (
|
||||
<SettingRelationshipsEditor entry={settingEntry} bookId={bookId} />
|
||||
)}
|
||||
{mentionOpen && mentionCandidates.length > 0 && (
|
||||
<div className="mention-dropdown" data-testid="mention-dropdown">
|
||||
{mentionCandidates.map((s) => (
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type cytoscape from 'cytoscape'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { useGraphStore } from '@renderer/stores/useGraphStore'
|
||||
import { useEditStore } from '@renderer/stores/useEditStore'
|
||||
import { initCytoscape, runLayout, type GraphSelection, INTIMACY_COLORS } from '@renderer/lib/graph-cytoscape'
|
||||
|
||||
export function CharacterGraphModal(): React.JSX.Element | null {
|
||||
const { t } = useTranslation()
|
||||
const bookId = useBookStore((s) => s.currentBookId)
|
||||
const { open, data, loading, filter, close, load, setFilter } = useGraphStore()
|
||||
const switchTarget = useEditStore((s) => s.switchTarget)
|
||||
const cyRef = useRef<cytoscape.Core | null>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [selection, setSelection] = useState<GraphSelection>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !bookId) return
|
||||
void load(bookId, filter)
|
||||
}, [open, bookId, filter, load])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !data || !containerRef.current) return
|
||||
cyRef.current = initCytoscape(containerRef.current, data, setSelection)
|
||||
return () => {
|
||||
cyRef.current?.destroy()
|
||||
cyRef.current = null
|
||||
setSelection(null)
|
||||
}
|
||||
}, [open, data])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const handleLayout = (name: 'cose-bilkent' | 'grid' | 'circle'): void => {
|
||||
if (cyRef.current) runLayout(cyRef.current, name)
|
||||
}
|
||||
|
||||
const handleEditSetting = (): void => {
|
||||
if (selection?.kind === 'node' && bookId) {
|
||||
close()
|
||||
void switchTarget({ kind: 'setting', id: selection.node.id })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay character-graph-modal" data-testid="character-graph-modal">
|
||||
<div className="modal-content modal-content--wide">
|
||||
<div className="modal-header">
|
||||
<h3>{t('graph.title')}</h3>
|
||||
<button type="button" className="modal-close" onClick={close}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="graph-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="graph-layout-cose"
|
||||
onClick={() => handleLayout('cose-bilkent')}
|
||||
>
|
||||
{t('graph.layout.cose')}
|
||||
</button>
|
||||
<button type="button" data-testid="graph-layout-grid" onClick={() => handleLayout('grid')}>
|
||||
{t('graph.layout.grid')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="graph-layout-circle"
|
||||
onClick={() => handleLayout('circle')}
|
||||
>
|
||||
{t('graph.layout.circle')}
|
||||
</button>
|
||||
<select
|
||||
className="form-control"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value as 'all' | 'connected')}
|
||||
>
|
||||
<option value="all">{t('graph.filter.all')}</option>
|
||||
<option value="connected">{t('graph.filter.connected')}</option>
|
||||
</select>
|
||||
</div>
|
||||
{data?.truncated && (
|
||||
<div className="graph-truncated-notice">{t('graph.truncated', { count: 100 })}</div>
|
||||
)}
|
||||
<div className="graph-body">
|
||||
<div
|
||||
id="character-graph-cy"
|
||||
ref={containerRef}
|
||||
data-testid="character-graph-cy"
|
||||
/>
|
||||
<aside className="graph-sidebar" data-testid="character-graph-sidebar">
|
||||
{loading && <div className="sidebar-empty">{t('cockpit.loading')}</div>}
|
||||
{!loading && !selection && (
|
||||
<div className="sidebar-empty">{t('graph.sidebar.hint')}</div>
|
||||
)}
|
||||
{selection?.kind === 'node' && (
|
||||
<>
|
||||
<div>{t('graph.sidebar.node', { name: selection.node.label })}</div>
|
||||
<button type="button" className="btn" onClick={handleEditSetting}>
|
||||
{t('graph.editSetting')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{selection?.kind === 'edge' && (
|
||||
<div>
|
||||
{t('graph.sidebar.edge', { label: selection.edge.label })}
|
||||
<div className="graph-intimacy-bar">
|
||||
<span
|
||||
style={{
|
||||
background: INTIMACY_COLORS[selection.edge.intimacy],
|
||||
width: `${selection.edge.intimacy * 20}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>{t('relationship.intimacy')}: {selection.edge.intimacy}</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { InspirationList } from '@renderer/components/inspiration/InspirationLis
|
||||
import { VersionModal } from '@renderer/components/version/VersionModal'
|
||||
import { LandmarkModal } from '@renderer/components/landmark/LandmarkModal'
|
||||
import { CockpitModal } from '@renderer/components/cockpit/CockpitModal'
|
||||
import { CharacterGraphModal } from '@renderer/components/graph/CharacterGraphModal'
|
||||
import { ChapterBridgeModal } from '@renderer/components/bridge/ChapterBridgeModal'
|
||||
import {
|
||||
editorDirtyAtom,
|
||||
@@ -107,6 +108,7 @@ export function EditorLayout(): React.JSX.Element {
|
||||
const bridgeChapterId = useCockpitStore((s) => s.bridgeChapterId)
|
||||
const clearBridgeChapter = useCockpitStore((s) => s.clearBridgeChapter)
|
||||
const updateChapterLocal = useBookStore((s) => s.updateChapterLocal)
|
||||
const settings = useBookStore((s) => s.settings)
|
||||
|
||||
const dirty = useAtomValue(editorDirtyAtom)
|
||||
const saving = useAtomValue(editorSavingAtom)
|
||||
@@ -120,6 +122,20 @@ export function EditorLayout(): React.JSX.Element {
|
||||
const bookMeta = useBookStore.getState().books.find((b) => b.id === currentBookId)
|
||||
const isChapterTarget = target?.kind === 'chapter'
|
||||
const chapterId = target?.kind === 'chapter' ? target.id : selectedChapterId
|
||||
const currentChapter = isChapterTarget && chapterId ? chapters.find((c) => c.id === chapterId) : null
|
||||
const characterSettings = settings.filter((s) => s.type === 'character')
|
||||
|
||||
const handlePovChange = async (povCharacterId: string | null): Promise<void> => {
|
||||
if (!currentBookId || !chapterId) return
|
||||
const updated = await ipcCall(() =>
|
||||
window.electronAPI.chapter.update({
|
||||
bookId: currentBookId,
|
||||
chapterId,
|
||||
povCharacterId
|
||||
})
|
||||
)
|
||||
updateChapterLocal(updated)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void pomodoroInit()
|
||||
@@ -507,6 +523,21 @@ export function EditorLayout(): React.JSX.Element {
|
||||
)}
|
||||
{isChapterTarget ? (
|
||||
<>
|
||||
<label className="status-pov-label">
|
||||
{t('chapter.pov')}
|
||||
<select
|
||||
data-testid="chapter-pov-select"
|
||||
value={currentChapter?.povCharacterId ?? ''}
|
||||
onChange={(e) => void handlePovChange(e.target.value || null)}
|
||||
>
|
||||
<option value="">{t('analytics.povUnassigned')}</option>
|
||||
{characterSettings.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<span>{t('status.chapter')}: {t('editor.words', { count: wordCount.chapter })}</span>
|
||||
<span>{t('status.volume')}: {t('editor.words', { count: wordCount.volume })}</span>
|
||||
<span>{t('status.book')}: {t('editor.words', { count: wordCount.book })}</span>
|
||||
@@ -532,6 +563,7 @@ export function EditorLayout(): React.JSX.Element {
|
||||
setBridgeOpen(true)
|
||||
}}
|
||||
/>
|
||||
<CharacterGraphModal />
|
||||
<ChapterBridgeModal
|
||||
open={bridgeOpen}
|
||||
chapterId={activeBridgeChapterId}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import type { SettingType } from '@shared/types'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { useEditStore } from '@renderer/stores/useEditStore'
|
||||
import { useGraphStore } from '@renderer/stores/useGraphStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
|
||||
const SETTING_TYPES: SettingType[] = [
|
||||
@@ -84,6 +85,13 @@ export function SettingList(): React.JSX.Element {
|
||||
})}
|
||||
</div>
|
||||
<div className="sidebar-footer">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="setting-open-graph"
|
||||
onClick={() => useGraphStore.getState().openModal()}
|
||||
>
|
||||
{t('graph.title')}
|
||||
</button>
|
||||
<button type="button" data-testid="setting-new" onClick={() => setShowDialog(true)}>
|
||||
+ {t('setting.new')}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { CharacterRelationship, SettingEntry } from '@shared/types'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
|
||||
interface SettingRelationshipsEditorProps {
|
||||
entry: SettingEntry
|
||||
bookId: string
|
||||
}
|
||||
|
||||
function parseRelationships(entry: SettingEntry): CharacterRelationship[] {
|
||||
const raw = entry.properties?.relationships
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw.filter(
|
||||
(r): r is CharacterRelationship =>
|
||||
r != null &&
|
||||
typeof r === 'object' &&
|
||||
typeof (r as CharacterRelationship).id === 'string' &&
|
||||
typeof (r as CharacterRelationship).targetId === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingRelationshipsEditor({
|
||||
entry,
|
||||
bookId
|
||||
}: SettingRelationshipsEditorProps): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const settings = useBookStore((s) => s.settings)
|
||||
const updateSettingLocal = useBookStore((s) => s.updateSettingLocal)
|
||||
const characters = settings.filter((s) => s.type === 'character' && s.id !== entry.id)
|
||||
const relationships = parseRelationships(entry)
|
||||
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [targetId, setTargetId] = useState('')
|
||||
const [label, setLabel] = useState('')
|
||||
const [intimacy, setIntimacy] = useState(3)
|
||||
|
||||
const targetName = (id: string): string => settings.find((s) => s.id === id)?.name ?? id
|
||||
|
||||
const syncLocal = (nextRels: CharacterRelationship[]): void => {
|
||||
updateSettingLocal({
|
||||
...entry,
|
||||
properties: { ...entry.properties, relationships: nextRels }
|
||||
})
|
||||
}
|
||||
|
||||
const handleAdd = async (): Promise<void> => {
|
||||
if (!targetId || !label.trim()) return
|
||||
const rel = await ipcCall(() =>
|
||||
window.electronAPI.graph.upsertRelationship(bookId, entry.id, {
|
||||
targetId,
|
||||
label: label.trim(),
|
||||
intimacy
|
||||
})
|
||||
)
|
||||
syncLocal([...relationships.filter((r) => r.id !== rel.id), rel])
|
||||
setShowForm(false)
|
||||
setTargetId('')
|
||||
setLabel('')
|
||||
setIntimacy(3)
|
||||
}
|
||||
|
||||
const handleDelete = async (relationshipId: string): Promise<void> => {
|
||||
await ipcCall(() =>
|
||||
window.electronAPI.graph.deleteRelationship(bookId, entry.id, relationshipId)
|
||||
)
|
||||
syncLocal(relationships.filter((r) => r.id !== relationshipId))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="setting-relationships" data-testid="setting-relationships">
|
||||
<h4>{t('relationship.title')}</h4>
|
||||
{relationships.map((r) => (
|
||||
<div key={r.id} className="setting-relationship-row">
|
||||
<span>
|
||||
{targetName(r.targetId)} · {r.label} · {r.intimacy}
|
||||
</span>
|
||||
<button type="button" aria-label="delete" onClick={() => void handleDelete(r.id)}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{showForm ? (
|
||||
<div className="setting-relationship-form">
|
||||
<select
|
||||
className="form-control"
|
||||
value={targetId}
|
||||
onChange={(e) => setTargetId(e.target.value)}
|
||||
>
|
||||
<option value="">{t('relationship.target')}</option>
|
||||
{characters.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
className="form-control"
|
||||
placeholder={t('relationship.label')}
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
/>
|
||||
<label>
|
||||
{t('relationship.intimacy')}
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={5}
|
||||
value={intimacy}
|
||||
onChange={(e) => setIntimacy(Number(e.target.value))}
|
||||
/>
|
||||
{intimacy}
|
||||
</label>
|
||||
<button type="button" className="btn btn-primary" onClick={() => void handleAdd()}>
|
||||
{t('dialog.save')}
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={() => setShowForm(false)}>
|
||||
{t('dialog.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="setting-relationship-add"
|
||||
onClick={() => setShowForm(true)}
|
||||
>
|
||||
+ {t('relationship.add')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -228,7 +228,7 @@ export function SettingsPage(): React.JSX.Element {
|
||||
{section === 'shortcuts' && <ShortcutEditor />}
|
||||
{section === 'about' && (
|
||||
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
|
||||
{t('app.name')} v0.9.0
|
||||
{t('app.name')} v1.0.0
|
||||
<br />
|
||||
{t('app.tagline')}
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import cytoscape from 'cytoscape'
|
||||
import coseBilkent from 'cytoscape-cose-bilkent'
|
||||
import type { CharacterGraphData, CharacterGraphEdge, CharacterGraphNode } from '@shared/types'
|
||||
|
||||
cytoscape.use(coseBilkent)
|
||||
|
||||
const INTIMACY_COLORS: Record<number, string> = {
|
||||
1: '#4ea8de',
|
||||
2: '#45b7aa',
|
||||
3: '#3fb950',
|
||||
4: '#f0883e',
|
||||
5: '#e0556a'
|
||||
}
|
||||
|
||||
export type GraphSelection =
|
||||
| { kind: 'node'; node: CharacterGraphNode }
|
||||
| { kind: 'edge'; edge: CharacterGraphEdge }
|
||||
| null
|
||||
|
||||
function toElements(data: CharacterGraphData): cytoscape.ElementDefinition[] {
|
||||
const nodes: cytoscape.ElementDefinition[] = data.nodes.map((n) => ({
|
||||
data: { id: n.id, label: n.label }
|
||||
}))
|
||||
const edges: cytoscape.ElementDefinition[] = data.edges.map((e) => ({
|
||||
data: {
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
label: e.label,
|
||||
intimacy: e.intimacy
|
||||
}
|
||||
}))
|
||||
return [...nodes, ...edges]
|
||||
}
|
||||
|
||||
export function initCytoscape(
|
||||
container: HTMLElement,
|
||||
data: CharacterGraphData,
|
||||
onSelect: (sel: GraphSelection) => void
|
||||
): cytoscape.Core {
|
||||
const intimacyStyles: cytoscape.Stylesheet[] = [1, 2, 3, 4, 5].map((level) => ({
|
||||
selector: `edge[intimacy = ${level}]`,
|
||||
style: {
|
||||
'line-color': INTIMACY_COLORS[level],
|
||||
'target-arrow-color': INTIMACY_COLORS[level]
|
||||
}
|
||||
}))
|
||||
|
||||
const cy = cytoscape({
|
||||
container,
|
||||
elements: toElements(data),
|
||||
style: [
|
||||
{
|
||||
selector: 'node',
|
||||
style: {
|
||||
label: 'data(label)',
|
||||
'text-valign': 'center',
|
||||
'text-halign': 'center',
|
||||
'background-color': '#6e7681',
|
||||
color: '#fff',
|
||||
'font-size': 11,
|
||||
width: 36,
|
||||
height: 36
|
||||
}
|
||||
},
|
||||
{
|
||||
selector: 'edge',
|
||||
style: {
|
||||
label: 'data(label)',
|
||||
'curve-style': 'bezier',
|
||||
'target-arrow-shape': 'triangle',
|
||||
'line-color': '#888',
|
||||
'target-arrow-color': '#888',
|
||||
width: 2,
|
||||
'font-size': 9,
|
||||
color: '#666'
|
||||
}
|
||||
},
|
||||
...intimacyStyles,
|
||||
{
|
||||
selector: ':selected',
|
||||
style: {
|
||||
'border-width': 2,
|
||||
'border-color': '#58a6ff'
|
||||
}
|
||||
}
|
||||
],
|
||||
layout: { name: 'cose-bilkent', animate: false }
|
||||
})
|
||||
|
||||
cy.on('tap', 'node', (evt) => {
|
||||
const id = evt.target.id()
|
||||
const node = data.nodes.find((n) => n.id === id)
|
||||
if (node) onSelect({ kind: 'node', node })
|
||||
})
|
||||
|
||||
cy.on('tap', 'edge', (evt) => {
|
||||
const id = evt.target.id()
|
||||
const edge = data.edges.find((e) => e.id === id)
|
||||
if (edge) onSelect({ kind: 'edge', edge })
|
||||
})
|
||||
|
||||
cy.on('tap', (evt) => {
|
||||
if (evt.target === cy) onSelect(null)
|
||||
})
|
||||
|
||||
return cy
|
||||
}
|
||||
|
||||
export function runLayout(cy: cytoscape.Core, name: 'cose-bilkent' | 'grid' | 'circle'): void {
|
||||
cy.layout({ name, animate: true }).run()
|
||||
}
|
||||
|
||||
export { INTIMACY_COLORS }
|
||||
@@ -0,0 +1,34 @@
|
||||
import { create } from 'zustand'
|
||||
import type { CharacterGraphData } from '@shared/types'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
|
||||
interface GraphState {
|
||||
open: boolean
|
||||
data: CharacterGraphData | null
|
||||
loading: boolean
|
||||
filter: 'all' | 'connected'
|
||||
openModal: () => void
|
||||
close: () => void
|
||||
load: (bookId: string, filter?: 'all' | 'connected') => Promise<void>
|
||||
setFilter: (filter: 'all' | 'connected') => void
|
||||
}
|
||||
|
||||
export const useGraphStore = create<GraphState>((set, get) => ({
|
||||
open: false,
|
||||
data: null,
|
||||
loading: false,
|
||||
filter: 'all',
|
||||
openModal: () => set({ open: true }),
|
||||
close: () => set({ open: false, data: null }),
|
||||
setFilter: (filter) => set({ filter }),
|
||||
load: async (bookId, filter) => {
|
||||
const f = filter ?? get().filter
|
||||
set({ loading: true })
|
||||
try {
|
||||
const data = await ipcCall(() => window.electronAPI.graph.getData(bookId, f))
|
||||
set({ data, filter: f })
|
||||
} finally {
|
||||
set({ loading: false })
|
||||
}
|
||||
}
|
||||
}))
|
||||
@@ -1844,3 +1844,146 @@
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.character-graph-modal .modal-content {
|
||||
max-width: 95vw;
|
||||
width: 1100px;
|
||||
}
|
||||
|
||||
.graph-toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.graph-body {
|
||||
display: flex;
|
||||
min-height: 480px;
|
||||
}
|
||||
|
||||
#character-graph-cy {
|
||||
min-height: 480px;
|
||||
flex: 1;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.graph-sidebar {
|
||||
width: 260px;
|
||||
padding: 12px;
|
||||
border-left: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.graph-truncated-notice {
|
||||
padding: 8px 16px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.setting-relationships {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.setting-relationship-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 4px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.setting-relationship-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.cockpit-analytics {
|
||||
margin-top: 16px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cockpit-analytics-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.hourly-heatmap {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.hourly-heatmap-cell {
|
||||
flex: 1;
|
||||
border-radius: 2px;
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.analytics-bar-row {
|
||||
display: grid;
|
||||
grid-template-columns: 80px 1fr 40px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.analytics-bar-track {
|
||||
height: 8px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pov-bar,
|
||||
.volume-bar {
|
||||
height: 8px;
|
||||
background: var(--accent);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.week-trend-bars {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
height: 52px;
|
||||
}
|
||||
|
||||
.week-trend-bar {
|
||||
flex: 1;
|
||||
min-width: 8px;
|
||||
background: var(--accent);
|
||||
border-radius: 2px 2px 0 0;
|
||||
}
|
||||
|
||||
.status-pov-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.status-pov-label select {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.graph-intimacy-bar {
|
||||
height: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 3px;
|
||||
margin: 8px 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.graph-intimacy-bar span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user