Initial release

This commit is contained in:
Damien Masson
2025-07-13 16:48:51 -04:00
parent c1d9b0d959
commit fe49fe4069
75 changed files with 17849 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
name: deploy
on:
push:
branches:
- master
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 23
- run: npm i
- run: npm run build
env:
BASE_URL: /VisualStoryWriting
DEV: true # Do not treat warnings as errors
- run: touch build/.nojekyll
- name: Push
uses: s0/git-publish-subdir-action@develop
env:
REPO: self
BRANCH: gh-pages
FOLDER: build
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
MESSAGE: "Build: ({sha}) {msg}"
SQUASH_HISTORY: true
+48
View File
@@ -0,0 +1,48 @@
# Visual Story-Writing: Writing by Manipulating Visual Representations
<img src="demo.gif">
## [Online Demo](https://damienmasson.com/VisualStoryWriting) / [How to build](#how-to-build-and-run) / [Publication](#publication)
This system automatically **visualizes** a story (chronological events, character and their actions and movements) and allows users to **edit** the story by manipulating these visual representations. For example:
- Hover over the timeline allows reviewing the chronology of events and visualizing the movements of the characters
- Connecting two characters suggests edits to the text to reflect the new interaction
- Moving a character suggests edits to the text to reflect the new position
- Reordering the events in the timeline suggests edits to the text to reflect the new chronology
The system relies on a GPT-4o to extract the information from the text and suggest edits.
## How to build and run
The code is written in TypeScript and uses React and Vite. To build and run the code, you will need to have Node.js installed on your machine. You can download it [here](https://nodejs.org/en/download/).
First install the dependencies:
```bash
npm install
```
Then build the code:
```bash
npm run dev
```
## How to use?
After entering your OpenAI API key, you can test Visual Story-Writing using the shortcuts or you can run the studies.
Note that the system was tested and developped for recent versions of **Google Chrome** or **Mozilla Firefox**.
## How to get an OpenAI API key?
Because Visual Story-Writing relies on the OpenAI API, you will need a key to make it work. You will need an account properly configured, see [here](https://platform.openai.com/account/api-keys) for more info.
Your key is never stored and the application runs locally and sends requests to the OpenAI API only.
## Can I try without an API key?
The systen depends on the OpenAI API to work. If you enter an incorrect key, you will still be able to go through the study but executing prompts will yield an error.
## Where are the video tutorials?
From the launcher, you can start the studies to see the exact ordering and video tutorials participants went through.
Alternatively, you can go in the ``public/videos`` to review all the video tutorials.
## Publication
Coming soon!
You can also find the paper on [arXiv](https://arxiv.org/abs/2410.07486)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 MiB

+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link href='https://fonts.googleapis.com/css?family=Inter' rel='stylesheet'>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🧙‍♂️</text></svg>">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Visual Story-Writing</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+9458
View File
File diff suppressed because it is too large Load Diff
+50
View File
@@ -0,0 +1,50 @@
{
"name": "visualstorywriting",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "NODE_ENV=development vite build --mode development --base=/VisualStoryWriting",
"build_sup": "vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@nextui-org/react": "^2.4.2",
"@xyflow/react": "^12.0.4",
"d3-force": "^3.0.0",
"diff": "^5.2.0",
"framer-motion": "^11.2.12",
"openai": "^4.52.0",
"partial-json": "^0.1.7",
"react": "^18.3.1",
"react-d3-tree": "^3.6.2",
"react-dom": "^18.3.1",
"react-icons": "^5.2.1",
"react-markdown": "^9.0.1",
"react-router-dom": "^6.26.1",
"slate": "^0.103.0",
"slate-history": "^0.109.0",
"slate-react": "^0.105.0",
"zod": "^3.23.8",
"zustand": "^4.5.2"
},
"devDependencies": {
"@types/d3-force": "^3.0.10",
"@types/diff": "^5.2.1",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.13.1",
"@typescript-eslint/parser": "^7.13.1",
"@vitejs/plugin-react": "^4.3.1",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.7",
"vite": "^5.3.1",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.4",
"typescript": "^5.2.2"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+52
View File
@@ -0,0 +1,52 @@
import { NextUIProvider } from '@nextui-org/react';
import { StrictMode } from 'react';
import { RouterProvider, createHashRouter } from 'react-router-dom';
import BaselineInterface from './study/BaselineInterface';
import StudyInterface from './study/StudyInterface';
import { useStudyStore } from './study/StudyModel';
import Launcher from './view/Launcher';
import VisualWritingInterface from './view/VisualWritingInterface';
function App() {
const router = createHashRouter([
{
path: 'free-form',
loader: () => {
useStudyStore.getState().setIsDataSaved(false);
return null;
},
element: <VisualWritingInterface />
},
{
path: 'study',
element: <StudyInterface />
},
{
path: 'baseline',
element: <BaselineInterface />
},
{
path: '/',
element: <Launcher />
}
],
/*{
basename: import.meta.env.BASE_URL
}*/
);
return (
<>
<StrictMode>
<NextUIProvider>
<RouterProvider router={router} />
</NextUIProvider>
</StrictMode>
</>
)
}
export default App
+69
View File
@@ -0,0 +1,69 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
:focus {outline:0;}
.suggest-addition {
color: #29350E;
background-color: #E1FDC5;
}
.highlight {
background-color: #bbe5ff;
border-radius: 5px;
}
.suggest-deletion {
color: #676666;
background-color: #EAEBE9;
text-decoration: line-through;
}
@keyframes pulseanim {
0% { opacity: 1;}
50% { opacity: 0.2;}
100% { opacity: 1;}
}
.loading {
animation: pulseanim 1.2s ease-in-out infinite;
}
.rd3t-link {
stroke-width: 2;
stroke: #6D6E6E !important;
}
.history-node:hover {
fill: #71A9F6;
}
a {
color: blue;
}
a:hover {
text-decoration: underline;
}
.node-entity:hover {
border: solid 1px #71A9F6 !important;
}
.overflow-scroll {
overflow: scroll !important;
}
+7
View File
@@ -0,0 +1,7 @@
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<App />
)
+173
View File
@@ -0,0 +1,173 @@
import { RawNodeDatum } from 'react-d3-tree';
import { create } from 'zustand';
import { useStudyStore } from '../study/StudyModel';
import { globalEditor } from '../view/TextEditor';
import { ModelState, useModelStore } from './Model';
import { VisualRefresher } from './prompts/textExtractors/VisualRefresher';
export interface HistoryNodeDatum extends RawNodeDatum {
state: ModelState;
children?: HistoryNodeDatum[];
}
/**
* Model
**/
interface HistoryModelState {
historyTree: HistoryNodeDatum | null;
positionInTree : number[];
redoPositionStack: number[][];
timestampLastAddedNode: number;
}
interface HistoryModelAction {
reset: () => void;
setPositionInTree: (position: number[]) => void;
getNodeAtPosition: (position: number[]) => {node: HistoryNodeDatum, parent: HistoryNodeDatum | null} | null;
findNodePosition: (node: HistoryNodeDatum) => number[] | null;
addHistoryNode: (state: ModelState) => void;
undo: () => void;
redo: () => void;
}
function getInitialState() : HistoryModelState {
return {
historyTree: null,
positionInTree: [],
redoPositionStack: [],
timestampLastAddedNode: 0
}
}
export const useHistoryModelStore = create<HistoryModelState & HistoryModelAction>()((set, get) => ({
...getInitialState(),
reset: () => set((state) => ({ ...getInitialState() })),
setPositionInTree: (position: number[]) => {
// Restore the state
const node = get().getNodeAtPosition(position);
if (node) {
useModelStore.setState(node.node.state);
// Make sure the editor is updated
globalEditor.children = node.node.state.textState;
globalEditor.onChange();
// Make sure the layout is clean
if (VisualRefresher.getInstance().onUpdate) {
VisualRefresher.getInstance().onUpdate();
}
useStudyStore.getState().logEvent("SET_POSITION_IN_HISTORY_TREE", { position });
set((state) => ({ positionInTree: position, redoPositionStack: [] }));
}
},
getNodeAtPosition: (position: number[]) => {
if (get().historyTree === null) {
return null;
}
let parent = null;
let node = get().historyTree as any as HistoryNodeDatum;
for (let index of position) {
if (node?.children && node.children[index]) {
parent = node;
node = node.children[index];
} else {
return null; // If the position is invalid, return undefined
}
}
return { node, parent };
},
findNodePosition: (targetNode: HistoryNodeDatum) => {
function searchNode(node: RawNodeDatum, path: number[]): number[] | null {
if (node === targetNode) {
return path; // Return the path when we find the target node
}
if (node.children) {
for (let i = 0; i < node.children.length; i++) {
const result = searchNode(node.children[i], [...path, i]);
if (result) {
return result; // Return the path if found in the subtree
}
}
}
return null; // Node not found in this branch
}
return searchNode(get().historyTree || {name: '', children: []}, []);
},
addHistoryNode: (state: ModelState) => {
// Two cases when adding a node
// 1) If we are on a node that is the last sibling, we add the new node as a child
// 2) If we are on a node that is not the last sibling, we add the new node as a sibling
const node: HistoryNodeDatum = {
state: state,
name: '',
attributes: {},
children: []
}
const position = get().positionInTree;
let newPosition : number[] = [];
let tree = get().historyTree;
const timestampLastAddedNode = get().timestampLastAddedNode;
if ((Date.now() - timestampLastAddedNode) < 700) {
// Last node was added so recently... This node is probably related, let's just merge it with the current node
const currentNodeAndParent = get().getNodeAtPosition(position);
newPosition = get().positionInTree;
if (currentNodeAndParent) {
currentNodeAndParent.node.state = useModelStore.getState();
}
} else {
if (tree === null) {
// The node becomes the root
tree = node;
newPosition = [];
} else {
const currentNodeAndParent = get().getNodeAtPosition(position);
if (currentNodeAndParent) {
const currentNode = currentNodeAndParent.node;
if (currentNode && currentNode.children) {
currentNode.children?.push(node);
newPosition = [...position, currentNode.children.length - 1];
}
}
}
}
set((state) => ({ historyTree: JSON.parse(JSON.stringify(tree)), positionInTree: newPosition, redoPositionStack: [], timestampLastAddedNode: Date.now() }));
},
undo: () => {
const position = get().positionInTree;
// Simply get rid of the last element
if (position.length > 0) {
const newPosition = position.slice(0, position.length - 1);
const redoStack = [...get().redoPositionStack, position];
get().setPositionInTree(newPosition);
set((state) => ({ redoPositionStack: redoStack }));
}
},
redo: () => {
const redoPositionStack = get().redoPositionStack;
if (redoPositionStack.length > 0) {
const position = redoPositionStack[redoPositionStack.length - 1];
const redoStack = [...redoPositionStack.slice(0, redoPositionStack.length - 1)];
get().setPositionInTree(position);
set((state) => ({ redoPositionStack: redoStack}));
}
}
}))
//
+93
View File
@@ -0,0 +1,93 @@
import { Node } from "@xyflow/react";
import { Simulation, SimulationNodeDatum, forceCollide, forceManyBody, forceSimulation, forceX, forceY } from "d3-force";
const simulationsDict: { [key: string]: Simulation<SimulationNodeDatum, undefined> } = {};
const tickCallbacks : any = {}
function runAllTickCallbacks() {
Object.keys(tickCallbacks).forEach(name => {
tickCallbacks[name]();
})
}
export class LayoutUtils {
static startSimulation(name : string, simulation : Simulation<SimulationNodeDatum, undefined>, onUpdateTick: () => void, maxTicks = 50) : Simulation<SimulationNodeDatum, undefined> {
let ticks = 0;
LayoutUtils.stopSimulation(name);
simulationsDict[name] = simulation;
tickCallbacks[name] = () => {
if (ticks > maxTicks) {
LayoutUtils.stopSimulation(name);
}
ticks++;
onUpdateTick();
}
simulation.on('tick', runAllTickCallbacks);
return simulation;
}
static stopSimulation(name : string) {
if (simulationsDict[name]) {
simulationsDict[name].stop();
delete simulationsDict[name];
delete tickCallbacks[name];
}
}
static stopAllSimulations() {
Object.keys(simulationsDict).forEach(name => {
LayoutUtils.stopSimulation(name);
})
}
static startNodeSimulation<T>(name: string, nodes : {data: T} & SimulationNodeDatum[], createSimulationCallback: (nodes: {data: T} & SimulationNodeDatum[]) => Simulation<SimulationNodeDatum, undefined>, setNodesCallback : (nodes: {data: T} & SimulationNodeDatum[]) => void, maxTicks = 50) {
const simulation = createSimulationCallback(nodes);
const onUpdateTick = () => {
setNodesCallback(nodes);
}
LayoutUtils.startSimulation(name, simulation, onUpdateTick, maxTicks);
}
static getSimulation(name : string) : Simulation<SimulationNodeDatum, undefined> | null {
return simulationsDict[name] || null;
}
static optimizeNodeLayout<T extends Record<string, unknown>>(name: string, flowNodes: Node<T>[], setNodesCallback: (flowNodes: Node<T>[]) => void, center: { x: number, y: number }, nodeRadius: number, maxTicks = 50): Simulation<SimulationNodeDatum, undefined> {
let simulation = LayoutUtils.getSimulation(name);
let nodes = null;
// New simulation
nodes = flowNodes.map(node => ({ id: node.id, x: node.position.x, y: node.position.y, data: node }));
simulation = forceSimulation(nodes)
.force("repel", forceManyBody().strength(-1000))
.force("x", forceX(d => center.x - ((d as any)?.data?.measured?.width || 0)/2).strength(0.05)) // Take into account the width of the entity node
.force("y", forceY(d => center.y - ((d as any)?.data?.measured?.height || 0)/2).strength(0.05)) // Take into account the height of the entity
.force("collide", forceCollide(nodeRadius)) as any;
LayoutUtils.startSimulation(name, simulation!, () => {
setNodesCallback([...flowNodes.map(node => {
const simNode = nodes.find(n => (n as any).id === node.id);
if (simNode) {
const x = (simNode as any).x;
const y = (simNode as any).y;
node.position = { x: x, y: y}
}
return {...node};
})])
}, maxTicks);
return simulation!;
}
}
+301
View File
@@ -0,0 +1,301 @@
import { Edge, Node } from '@xyflow/react';
import * as Diff from 'diff';
import OpenAI from 'openai';
import { Descendant, Node as SlateNode } from 'slate';
import { create } from 'zustand';
import { shallow } from 'zustand/shallow';
import { useStudyStore } from '../study/StudyModel';
import { dataTextD } from '../study/data/TextD';
import { globalEditor } from '../view/TextEditor';
import { useHistoryModelStore } from './HistoryModel';
import { SlateUtils } from './SlateUtils';
import { TextActionMatch, TextUtils } from './TextUtils';
import { extractedEntitiesToNodeEntities } from './prompts/textExtractors/EntitiesExtractor';
import { extractedLocationsToNodeLocations } from './prompts/textExtractors/LocationsExtractor';
import { extractedActionsToEdgeActions } from './prompts/textExtractors/SentenceActionsExtractor';
const hashSplitted = window.location.hash.split("?");
const search = hashSplitted[hashSplitted.length-1]
const params = new URLSearchParams(search);
const key = params.get('k');
let openaiKey = ""
if (!key) {
if ("VITE_OPENAI_API_KEY" in import.meta.env) {
openaiKey = import.meta.env.VITE_OPENAI_API_KEY;
} /*else {
throw new Error("No key provided in the URL parameters");
}*/
} else {
openaiKey = atob(key)
}
export const openai = new OpenAI({
apiKey: openaiKey,
dangerouslyAllowBrowser: true
});
export interface EntityProperty {
name: string
value: number
}
export type Entity = {
name: string
emoji: string
properties: EntityProperty[]
}
export type EntityNode = Node<Entity>;
export type Action = {
name: string
sourceLocation: string
targetLocation: string
passage: string
}
export type ActionEdge = Edge<Action>;
export type Location = {
name: string
emoji: string
}
export type LocationNode = Node<Location>;
const hardcodedText = `Anna sat on the beach, watching the waves crash against the shore. The wind blew her hair around, but she didnt mind. She loved the sound of the ocean. It helped her forget her worries, at least for a little while. She had been thinking about her brother, David, who lived far away. They hadnt spoken in weeks, and she missed him.
David was in the city, sitting at his desk, staring at his computer. He was tired from a long day of work. His job was stressful, and he often felt lonely in the big, noisy city. He wanted to call Anna, but he was afraid she might be too busy. He knew she was going through a tough time, and he didnt want to add to her troubles.
Meanwhile, their friend Emma was in the mountains, hiking up a trail. She loved the peacefulness of nature. The trees were tall, and the air was fresh. As she reached the top of the hill, she thought about Anna and David. They used to do everything together, but now they were all in different places. She hoped they could reunite soon, even if just for a little while.`
const hardcodedData = dataTextD
/**
* Model
**/
export interface ModelState {
entityNodes: EntityNode[];
locationNodes: LocationNode[];
actionEdges: ActionEdge[];
textState: Descendant[];
text: string;
suggestModeUntilTimestamp: number;
selectedNodes: string[];
selectedEdges: string[];
textActionMatches: TextActionMatch[];
isStale: boolean;
isReadOnly: boolean;
highlightedActionsSegment: { start: number, end: number } | null;
filteredActionsSegment: { start: number, end: number } | null;
highlightedEntities: string[];
}
interface ModelAction {
reset: () => void;
getFilteredEntityNodes: (actionFilter: { start: number, end: number } | null) => EntityNode[];
getFilteredActionEdges: (actionFilter: { start: number, end: number } | null) => ActionEdge[];
getFilteredLocationNodes: (actionFilter: { start: number, end: number } | null) => LocationNode[];
setEntityNodes: (nodes: EntityNode[]) => void;
setActionEdges: (edges: ActionEdge[]) => void;
setLocationNodes: (nodes: LocationNode[]) => void;
setTextState: (textState: Descendant[], updateEditor?: boolean, addHistoryNode?: boolean) => void;
suggestNextTextChanges: () => void;
acceptSuggestions: () => void;
rejectSuggestions: () => void;
isTextSuggested: () => boolean;
setSelectedNodes: (nodes: string[]) => void;
setSelectedEdges: (edges: string[]) => void;
setHighlightedActionsSegment: (startActionId: number | null, endActionId: number | null) => void;
setFilteredActionsSegment: (startActionId: number | null, endActionId: number | null) => void;
setHighlightedEntities: (entities: string[]) => void;
setIsStale: (isStale: boolean) => void;
setOpenAIKey: (key: string) => void
setIsReadOnly: (isReadOnly: boolean) => void;
}
function getInitialState() {
const initialTextState = [
{
children: [{
text: hardcodedText }]
},
]
const text = SlateUtils.stateToText(initialTextState);;
const entityNodes = extractedEntitiesToNodeEntities(hardcodedData);
const locationNodes = extractedLocationsToNodeLocations(hardcodedData);
const actionEdges = hardcodedData.actions.map(h => extractedActionsToEdgeActions({actions: [h]}, h.passage, entityNodes)).flat();
const initialState: ModelState = {
entityNodes: entityNodes,
actionEdges: actionEdges,
locationNodes: locationNodes,
suggestModeUntilTimestamp: 0,
selectedNodes: [],
selectedEdges: [],
isStale: false,
highlightedActionsSegment: null,
filteredActionsSegment: null,
highlightedEntities: [],
textActionMatches: TextUtils.matchActionsToText(actionEdges.map((edge) => edge.data!), text),
textState: initialTextState,
text: text,
isReadOnly: false
}
return initialState;
}
export const useModelStore = create<ModelState & ModelAction>()((set, get) => ({
...getInitialState(),
reset: () => set((state) => ({ ...getInitialState() })),
getFilteredEntityNodes: (actionFilter: { start: number, end: number } | null) => {
if (actionFilter === null || actionFilter.start === 0 && actionFilter.end === get().actionEdges.length - 1) {
return get().entityNodes;
}
// Filter to only keep the nodes that are not filtered out
const filteredIds = get().getFilteredActionEdges(actionFilter).map((edge) => [edge.source, edge.target]).flat();
return get().entityNodes.filter((node) => filteredIds.includes(node.id));
},
getFilteredActionEdges: (actionFilter: { start: number, end: number } | null) => {
const startIdx = actionFilter !== null ? Math.min(get().actionEdges.length, actionFilter.start) : 0;
const endIdx = actionFilter !== null ? Math.min(get().actionEdges.length, actionFilter.end + 1) : get().actionEdges.length;
return get().actionEdges.slice(startIdx, endIdx);
},
getFilteredLocationNodes: (actionFilter: { start: number, end: number } | null) => {
// Filter to only keep the nodes that are not filtered out
const filteredLocations = get().getFilteredActionEdges(actionFilter).map((edge) => [edge.data!.sourceLocation, edge.data!.targetLocation]).flat();
return get().locationNodes.filter((node) => filteredLocations.includes(node.data.name));
},
setEntityNodes: (nodes) => {
set((state) => ({ entityNodes: nodes }))
},
setLocationNodes: (nodes) => {
set((state) => ({ locationNodes: nodes }))
},
setActionEdges: (edges) => {
// Find the position of the actions in the text
const textActionMatches = TextUtils.matchActionsToText(edges.map((edge) => edge.data!), get().text);
set((state) => ({ actionEdges: edges, textActionMatches: textActionMatches }));
},
setTextState: (textState, updateEditor = true, addHistoryNode = true) => {
const text = SlateUtils.stateToText(textState);
const isTextDifferent = get().text !== text;
if (isTextDifferent && get().suggestModeUntilTimestamp > Date.now()) {
// We are in suggestion mode, we should mark the changes as suggestions
// Calculate the differences and create the marks
const differences = Diff.diffWordsWithSpace(get().text, text);
// Construct the new state that highlights the differences
const newState: Descendant[] = [];
for (const difference of differences) {
if (difference.removed) {
newState.push({ text: difference.value, removed: true } as any);
} else if (difference.added) {
newState.push({ text: difference.value, added: true } as any);
} else {
// No modification to this word, it stays the same
newState.push({ text: difference.value } as any);
}
}
textState = [{ children: newState, type: "paragraph" } as any];
}
if (isTextDifferent) {
// Update the text and the position of the actions in the text (at least do it as best it can)
const textActionMatches = TextUtils.matchActionsToText(get().actionEdges.map((edge) => edge.data!), text);
set((state) => ({ textState: textState, text: text, textActionMatches: textActionMatches, isStale: true }));
if (addHistoryNode) useHistoryModelStore.getState().addHistoryNode(get());
} else {
// Only update the state because this one might still be different even if invisibly so
set((state) => ({ textState: textState }));
}
if (updateEditor) {
globalEditor.children = textState
globalEditor.onChange();
}
},
/**
* All modifications done to the text in the next 200ms (arbitrary) will be marked as suggestions
*/
suggestNextTextChanges: () => {
set((state) => ({ suggestModeUntilTimestamp: Date.now() + 200 }));
},
acceptSuggestions: () => {
let newState = (JSON.parse(JSON.stringify(get().textState))[0] as any)
newState.children = newState.children.filter((node: any) => node.removed === undefined);
newState.children = newState.children.map((node: any) => ({ text: node.text }));
set((state) => ({ textState: [newState]}));
globalEditor.children = [newState]
globalEditor.onChange();
},
rejectSuggestions: () => {
let newState = (JSON.parse(JSON.stringify(get().textState))[0] as any)
newState.children = newState.children.filter((node: any) => node.added === undefined);
newState.children = newState.children.map((node: any) => ({ text: node.text }));
set((state) => ({ textState: [newState]}));
globalEditor.children = [newState]
globalEditor.onChange();
},
isTextSuggested: () => {
return (get().textState[0] as any).children.some((node: any) => node.removed !== undefined || node.added !== undefined);
},
getText: () => {
return get().textState.map((node: any) => SlateNode.string({ children: node.children.filter((c: any) => c.removed === undefined) })).join("\n");
},
setSelectedNodes: (nodes) => {
if (!shallow(get().selectedNodes, nodes)) {
set((state) => ({ selectedNodes: nodes }));
}
},
setSelectedEdges: (edges) => {
if (!shallow(get().selectedEdges, edges)) {
// Make sure the right edge is selected in the list of edges
const allEdges = get().actionEdges;
let modified = false;
for (const edge of allEdges) {
edge.selected = false;
if (edges.includes(edge.id) && !edge.selected) {
modified = true;
edge.selected = true;
}
}
if (modified) set((state) => ({ actionEdges: allEdges }));
useStudyStore.getState().logEvent("EDGES_SELECTED", { edges });
set((state) => ({ selectedEdges: edges }));
}
},
setHighlightedActionsSegment: (startActionId, endActionId) => {
if (get().highlightedActionsSegment?.start !== startActionId || get().highlightedActionsSegment?.end !== endActionId) {
set((state) => ({ highlightedActionsSegment: startActionId !== null && endActionId !== null ? { start: startActionId, end: endActionId } : null }));
}
},
setFilteredActionsSegment: (startActionId, endActionId) => {
// Only modify if different (zustand is not clever enough to do this by itself)
if (get().filteredActionsSegment?.start !== startActionId || get().filteredActionsSegment?.end !== endActionId) {
set((state) => ({ filteredActionsSegment: startActionId !== null && endActionId !== null ? { start: startActionId, end: endActionId } : null }));
}
},
setHighlightedEntities: (entities) => {
set((state) => ({ highlightedEntities: entities }));
},
setIsStale: (isStale) => {
set((state) => ({ isStale: isStale }));
},
setOpenAIKey: (key) => {
openai.apiKey = key;
},
setIsReadOnly: (isReadOnly) => {
set((state) => ({ isReadOnly: isReadOnly }));
}
}))
+90
View File
@@ -0,0 +1,90 @@
// Static class with a bunch of utility functions for text manipulation
import { Node, Point, Selection, Node as SlateNode } from "slate";
export class SlateUtils {
/**
* Converts an index position in a string to a Slate Point
* @param node
* @param strIndex
* @param startStrIndex
* @returns
*/
static toSlatePoint(node : any, strIndex : number, startStrIndex = 0, isLast = false) : Point | null {
if (node.text !== undefined) {
if (!node.removed && ((isLast && startStrIndex + node.text.length >= strIndex) || startStrIndex + node.text.length > strIndex)) {
return { path: [], offset: strIndex - startStrIndex };
}
return null;
} else if (node.children !== undefined) {
for (let i = 0; i < node.children.length; i++) {
const child = node.children[i];
const point = SlateUtils.toSlatePoint(child, strIndex, startStrIndex, i === node.children.length - 1);
if (point) {
return { path: [i, ...point.path], offset: point.offset };
}
if (child.removed === undefined) startStrIndex += Node.string(child).length;
}
}
else if (Array.isArray(node)) {
return SlateUtils.toSlatePoint({children: node}, strIndex, startStrIndex);
}
return null;
}
/**
* Converts a Slate Point to an index position in a string
* @param node
* @param point
* @param startStrIndex
* @returns
*/
static toStrIndex(state : Node[], point : Point) : number {
const texts = Node.texts({children: state} as any, {from: [0, 0], to: point.path})
let strIndex = 0;
for (const [node, path] of texts) {
if ((node as any).removed) continue;
if (path + "" === point.path + "") {
return strIndex + point.offset;
}
strIndex += Node.string(node).length;
}
return strIndex;
}
/**
* Checks if a point is valid for a given Slate state
* @param point
* @param state
* @returns
*/
static isPointValidForState(point : Point, state : Node[]) : boolean {
let element = {children: state} as any;
for (const elementId of point.path) {
if (elementId >= element.children.length) {
return false;
}
element = element.children[elementId];
}
return point.offset <= Node.string(element).length;
}
static isSelectionValidForState(selection : Selection, state : Node[]) : boolean {
return SlateUtils.isPointValidForState(selection!.anchor, state) && SlateUtils.isPointValidForState(selection!.focus, state);
}
static stateToText(state : Node[]) : string {
return state.map((node: any) => SlateNode.string({ children: node.children.filter((c: any) => c.removed === undefined) })).join("\n");
}
}
// @ts-ignore
window.SlateUtils = SlateUtils;
+226
View File
@@ -0,0 +1,226 @@
import * as Diff from 'diff';
import { Action } from "./Model";
export interface TextActionMatch {
action: Action;
start: number;
end: number;
isExact: boolean;
}
const punctuationMarks = [".", ",", ";", ":", "!", "?", "(", ")", "[", "]", "{", "}", "<", ">", "\"", "'"];
export class TextUtils {
/**
* Find all the matching strings in str and return their starting indices
* @param str
* @param search
* @returns
*/
static findAllMatches(str: string, search: string): number[] {
const indices: number[] = [];
let startIndex = 0;
let index;
while ((index = str.indexOf(search, startIndex)) > -1) {
indices.push(index);
startIndex = index + search.length;
}
return indices;
}
/**
* Clean up a string to make it easier to be matched against (mostly for GPT because it seems to mess with special characters)
* @param str
* @param replacement
* @returns
*/
static prepareStringForMatching(str: string, replacement = " "): string {
return str.replace(/[^a-zA-Z0-9]/g, replacement).toLocaleLowerCase();
}
/**
* Tries very hard to find the action in the text. Implements some fuzzy matching if an exact match is not found
* @param actions
* @param text
* @returns
*/
static matchActionsToText(actions : Action[], text: string): TextActionMatch[] {
// Simplify the string as much as possible to simplify the matching
// Also seems like GPT struggles with preserving special characters and spaces...
text = TextUtils.prepareStringForMatching(text);
// Most naive solution, we first match all the passages to the text
let matches : TextActionMatch[] = [];
for (const action of actions) {
let passage = TextUtils.prepareStringForMatching(action.passage);
const start = text.indexOf(passage);
if (start !== -1) {
matches.push({start, end: start + passage.length, action, isExact: true});
} else {
matches.push({start: -1, end: -1, action, isExact: false});
}
}
// For the remaining strings that could not be found, we do some fuzzy matching
// Actions should appear in chronological order in the text
// This means we can already approximate the general area the missing text should be by looking at its known neighbours
let lastEnd = 0;
matches = matches.map((match, index) => {
if (match.end === -1) {
// This passage was not matched yet
// We first approximate its position by looking at its neighbors
match.start = lastEnd;
match.end = text.length;
// Refine the endPos by looking at the next passage
const closestNextRange = matches.slice(index+1).find((r) => r.start !== null);
if (closestNextRange) {
match.end = closestNextRange.start!;
}
// Now we can look for the passage in a narrower passage
const textBeingSearched = text.slice(match.start, match.end);
// We look for the first and last long subsequence in common, and mark those as the start and end
let subsequenceIdx = 0;
let startIdx = -1;
let endIdx = -1;
Diff.diffWords(actions[index].passage, textBeingSearched, {ignoreWhitespace: true}).forEach((part) => {
if (!part.added && !part.removed) {
// This is a common subsquence, but we only consider it if is longer than 4 characters and 2 words
const words = part.value.split(" ");
// Remove all the spaces from the value to only count visible characters
const characters = part.value.replace(/\s/g, '');
if (words.length > 2 && characters.length > 5) {
if (startIdx === -1) startIdx = subsequenceIdx;
endIdx = subsequenceIdx + part.value.length;
}
}
if (!part.removed) {
subsequenceIdx += part.value.length;
}
});
if (startIdx !== -1 && endIdx !== -1) {
match.end = match.start + endIdx;
match.start += startIdx;
match.isExact = true; // Technically not exact, but close enough
}
} else {
lastEnd = match.end;
}
return match;
});
return matches;
}
/**
* Find the actions at a given position/range in the text
* @param textActionMatches
* @param start
* @param end
* @param precedingIfNoMatch If no action found, returns the actions that precede the range
* @returns
*/
static getActionsAtPosition(textActionMatches: TextActionMatch[], start: number, end: number, precedingIfNoMatch : boolean = false): {action: Action, index: number}[] {
const results = [];
let actionBefore : {action: Action, index: number, endPos: number}[] = [];
for (let i = 0; i < textActionMatches.length; i++) {
const match = textActionMatches[i];
if (actionBefore.length > 0 && actionBefore[0].endPos === match.end) {
actionBefore.push({action: match.action, index: i, endPos: match.end});
}
if (match.end < start && (actionBefore.length === 0 || actionBefore[0].endPos < match.end)) {
actionBefore = [{action: match.action, index: i, endPos: match.end}];
}
// If the range intersects the action
if (match.start <= end && match.end >= start) {
results.push({action: match.action, index: i});
}
}
return results.length === 0 && precedingIfNoMatch && actionBefore.length > 0 ? actionBefore : results;
}
static caretPositionFromPoint(x: number, y: number) : {offsetNode : Node, offset : number} | null {
if ((document as any).caretPositionFromPoint) {
return (document as any).caretPositionFromPoint(x, y);
} else if (document.caretRangeFromPoint) {
// Use WebKit-proprietary fallback method
const range = document.caretRangeFromPoint(x, y);
if (range) {
return { offsetNode: range.startContainer, offset: range.startOffset };
}
}
return null;
}
// Function to make a string beginning and end match another string so that it could "fit" wherever that other string was placed
// The reason of this function is mostly because the LLM often adds an upper case and punctuation to every result, even if they are just text fragments. So this function mitigates that problem.
static getFittingString(inputString: string, modelString: string) : string {
// The result has to match with what appears before and after
let result = inputString;
if (result.length <= 1) {
return result;
}
// Extract the spaces before and after the selected text
const spacesBefore = modelString.match(/^\s*/);
const spaceBeforeStr = spacesBefore ? spacesBefore[0] : "";
const spacesAfter = modelString.match(/\s*$/);
const spaceAfterStr = spacesAfter ? spacesAfter[0] : "";
// Remove the spaces before and after the model string and input string
modelString = modelString.trim();
result = result.trim();
const firstChar = modelString[0];
const lastChar = modelString[modelString.length - 1];
const firstCharResult = result[0];
const lastCharResult = result[result.length - 1];
// Make the first letter of the result match the case of the first letter of the selected text
if (firstChar === firstChar.toUpperCase()) {
result = result[0].toUpperCase() + result.substring(1);
} else {
result = result[0].toLowerCase() + result.substring(1);
}
// Use the punctuation from modelString instead of/in addition to result
if (lastChar !== lastCharResult) {
// If result finishes with some punctuation, remove it
if (punctuationMarks.includes(lastCharResult)) {
result = result.substring(0, result.length - 1);
}
// If the selected text finishes with some punctuation, add it to the result
if (punctuationMarks.includes(lastChar)) {
result = result + lastChar;
}
}
// Do the same for the first character
if (firstChar !== firstCharResult) {
// If result starts with some punctuation, remove it
if (punctuationMarks.includes(firstCharResult)) {
result = result.substring(1);
}
// If the selected text starts with some punctuation, add it to the result
if (punctuationMarks.includes(firstChar)) {
result = firstChar + result;
}
}
// Add the spaces before and after the selected text
return spaceBeforeStr + result + spaceAfterStr;
}
}
+33
View File
@@ -0,0 +1,33 @@
import { create } from 'zustand';
/**
* Model
**/
interface ViewModelState {
hoveredLocation: string | null;
textIsBeingEdited: boolean;
}
interface ViewModelAction {
reset: () => void;
setHoveredLocation: (location: string | null) => void;
setTextIsBeingEdited: (isBeingEdited: boolean) => void;
}
const initialState: ViewModelState = {
hoveredLocation: null,
textIsBeingEdited: false
}
export const useViewModelStore = create<ViewModelState & ViewModelAction>()((set, get) => ({
...initialState,
reset: () => set((state) => ({ ...initialState })),
setHoveredLocation: (location) => set((state) => ({ hoveredLocation: location })),
setTextIsBeingEdited: (isBeingEdited) => set((state) => ({ textIsBeingEdited: isBeingEdited })),
}))
//
@@ -0,0 +1,36 @@
import { useStudyStore } from "../../../study/StudyModel";
import { Entity } from "../../Model";
import { TargettedTextEditPrompt } from "./TargettedTextEdit";
export class AddActionPrompt extends TargettedTextEditPrompt {
source : Entity;
target : Entity;
newAction : string;
constructor(source : Entity, target : Entity, newAction : string) {
super();
this.source = source;
this.target = target;
this.newAction = newAction;
useStudyStore.getState().logEvent("ADD_ACTION_PROMPT", {source: source.name, target: target.name, newAction: newAction});
}
getGlobalPrompt(text: string): string {
return `${text}
SOURCE: ${this.source.name}
TARGET: ${this.target.name}
Rewrite the story so that SOURCE also ${this.newAction} TARGET`;
}
getTargettedPrompt(precedingText: string, textToModify: string, followingText: string): string {
return `${precedingText} <blank> ${followingText}
<blank>: ${textToModify}
SOURCE: ${this.source.name}
TARGET: ${this.target.name}
Rewrite <blank> to add that SOURCE also ${this.newAction} TARGET.\n\n<blank>: `;
}
}
@@ -0,0 +1,59 @@
import { useStudyStore } from "../../../study/StudyModel";
import { Action, Entity, useModelStore } from "../../Model";
import { TargettedTextEditPrompt } from "./TargettedTextEdit";
export class ChangeActionPrompt extends TargettedTextEditPrompt {
source: Entity;
target: Entity;
previousAction: Action;
newAction: Action;
constructor(source: Entity, target: Entity, previousAction: Action, newAction: Action) {
super();
this.source = source;
this.target = target;
this.previousAction = previousAction;
this.newAction = newAction;
useStudyStore.getState().logEvent("CHANGE_ACTION_PROMPT", { source: source.name, target: target.name, previousAction: previousAction.name, newAction: newAction.name });
}
isTargetted(): boolean {
return true; // An action is always targetted... We know exactly where it happened
}
splitTextBasedOnSelection(): { precedingText: string, textToModify: string, followingText: string } | null {
const actionMatch = useModelStore.getState().textActionMatches.filter((match) => match.action.passage === this.previousAction.passage)[0];
if (actionMatch) {
const precedingText = useModelStore.getState().text.slice(0, actionMatch.start);
const textToModify = useModelStore.getState().text.slice(actionMatch.start, actionMatch.end);
const followingText = useModelStore.getState().text.slice(actionMatch.end);
return { precedingText, textToModify, followingText };
}
return null;
}
getGlobalPrompt(text: string): string {
return `${text}
SOURCE: ${this.source.name}
TARGET: ${this.target.name}
PREVIOUS: ${this.previousAction.name}
NEW: ${this.newAction.name}
Rewrite the story so that SOURCE does NEW instead of PREVIOUS`;
}
getTargettedPrompt(precedingText: string, textToModify: string, followingText: string): string {
return `${precedingText} TEXT_TO_REWRITE ${followingText}
TEXT_TO_REWRITE: ${textToModify}
SOURCE: ${this.source.name}
TARGET: ${this.target.name}
PREVIOUS: ${this.previousAction.name}
NEW: ${this.newAction.name}
Rewrite TEXT_TO_REWRITE so that SOURCE does NEW instead of PREVIOUS`;
}
}
@@ -0,0 +1,24 @@
import { useStudyStore } from "../../../study/StudyModel";
import { Entity, useModelStore } from "../../Model";
import { TextEditPrompt } from "./TextEditPrompt";
export class ChangePropertyPrompt extends TextEditPrompt {
entity: Entity;
propertyName: string;
previousValue: number;
newValue: number;
constructor(entity : Entity, propertyName : string, previousValue : number, newValue : number) {
super();
this.entity = entity;
this.propertyName = propertyName;
this.previousValue = previousValue;
this.newValue = newValue;
useStudyStore.getState().logEvent("CHANGE_PROPERTY_PROMPT", { entity: entity.name, propertyName: propertyName, previousValue: previousValue, newValue: newValue });
}
getPrompt(): string {
const adj = this.propertyName.toLowerCase();
return `${useModelStore.getState().text}\n\nOn a scale from 1 being low ${adj} and 10 being high ${adj}, ${this.entity.name}'s ${adj} is currently a ${this.previousValue}. Slightly rewrite the story so that the ${adj} of ${this.entity.name} becomes a ${this.newValue}`;
}
}
@@ -0,0 +1,69 @@
import { useStudyStore } from "../../../study/StudyModel";
import { SpatialEntity } from "../../../view/locationView/LocationsEditor";
import { Location, useModelStore } from "../../Model";
import { TargettedTextEditPrompt } from "./TargettedTextEdit";
export class MoveEntityPrompt extends TargettedTextEditPrompt {
entity: SpatialEntity;
location: Location;
constructor(entity : SpatialEntity, location : Location) {
super();
this.entity = entity;
this.location = location;
useStudyStore.getState().logEvent("MOVE_ENTITY_PROMPT", {entity: entity.name, location: location.name});
}
isUnkwownLocation(): boolean {
return this.location.name === "unknown";
}
getGlobalPrompt(text: string): string {
return `${text}\n\nRewrite the story so that "${this.entity.name}" ${this.isUnkwownLocation() ? "" : "never goes to the \"" + this.entity.location + "\" but instead"} goes to the "${this.location.name}"`;
}
getTargettedPrompt(precedingText: string, textToModify: string, followingText: string): string {
return `${precedingText} TEXT_TO_REWRITE ${followingText}\n\nTEXT_TO_REWRITE: ${textToModify}\n\n"` +
(this.isUnkwownLocation() ? "" : `${this.entity.name}" is currently located in the "${this.location.name}". `) +
`Rewrite TEXT_TO_REWRITE so that it is clear that "${this.entity.name}" is located in the location "${this.location.name}"`;
}
execute(): void {
super.execute();
// Update our model to match the new location
const actionSelection = useModelStore.getState().filteredActionsSegment;
const actionEdges = useModelStore.getState().actionEdges;
const startIdx = actionSelection? actionSelection.start : 0;
const endIdx = actionSelection? actionSelection.end : actionEdges.length;
for (let idx = startIdx; idx < actionEdges.length; idx++) {
const action = actionEdges[idx];
const sourceNode = useModelStore.getState().entityNodes.find(entity => entity.id === action.source);
const targetNode = useModelStore.getState().entityNodes.find(entity => entity.id === action.target);
if (idx >= endIdx) {
// We only continue until finding the first mention of the entity that is in a different location than the one we are changing
if (sourceNode && sourceNode.data.name === this.entity.name && action.data?.sourceLocation !== this.entity.location ||
targetNode && targetNode.data.name === this.entity.name && action.data?.targetLocation !== this.entity.location) {
break;
}
}
// Update all the actions that had the entity at the old location
if (action.data?.sourceLocation === this.entity.location) {
if (sourceNode && sourceNode.data.name === this.entity.name) {
action.data.sourceLocation = this.location.name;
}
}
if (action.data?.targetLocation === this.entity.location) {
if (targetNode && targetNode.data.name === this.entity.name) {
action.data.targetLocation = this.location.name;
}
}
}
useModelStore.getState().setActionEdges(actionEdges);
}
}
@@ -0,0 +1,38 @@
import { useStudyStore } from "../../../study/StudyModel";
import { Action, Entity } from "../../Model";
import { TargettedTextEditPrompt } from "./TargettedTextEdit";
export class RemoveActionPrompt extends TargettedTextEditPrompt {
source : Entity;
target : Entity;
action : Action;
constructor(source : Entity, target : Entity, action : Action) {
super();
this.source = source;
this.target = target;
this.action = action;
useStudyStore.getState().logEvent("REMOVE_ACTION_PROMPT", {source: source.name, target: target.name, action: action.name});
}
getGlobalPrompt(text: string): string {
return `${text}
SOURCE: ${this.source.name}
TARGET: ${this.target.name}
ACTION: ${this.action.name}
Rewrite the story so that SOURCE does not do ACTION to TARGET`;
}
getTargettedPrompt(precedingText: string, textToModify: string, followingText: string): string {
return `${precedingText} TEXT_TO_REWRITE ${followingText}
SOURCE: ${this.source.name}
TARGET: ${this.target.name}
ACTION: ${this.action.name}
TEXT_TO_REWRITE: ${textToModify}
Rewrite TEXT_TO_REWRITE so that SOURCE does not do ACTION to TARGET`;
}
}
@@ -0,0 +1,21 @@
import { useStudyStore } from "../../../study/StudyModel";
import { Entity } from "../../Model";
import { TargettedTextEditPrompt } from "./TargettedTextEdit";
export class RemoveEntityPrompt extends TargettedTextEditPrompt {
entity: Entity;
constructor(entity : Entity) {
super();
this.entity = entity;
useStudyStore.getState().logEvent("REMOVE_ENTITY_PROMPT", {entity: entity.name});
}
getGlobalPrompt(text: string): string {
return `${text}\n\nRewrite the story so that there is no "${this.entity.name}"`;
}
getTargettedPrompt(precedingText: string, textToModify: string, followingText: string): string {
return `${precedingText} TEXT_TO_REWRITE ${followingText}\n\nTEXT_TO_REWRITE: ${textToModify}\n\nRewrite TEXT_TO_REWRITE so that there is no "${this.entity.name}"`;
}
}
@@ -0,0 +1,137 @@
import { useStudyStore } from "../../../study/StudyModel";
import { Action, ActionEdge, useModelStore } from "../../Model";
import { TextEditPrompt } from "./TextEditPrompt";
export class ReorderActionPrompt extends TextEditPrompt {
action: Action;
previousPosition: number;
newPosition: number;
constructor(action: Action, previousPosition: number, newPosition: number) {
super();
this.action = action;
this.previousPosition = previousPosition;
this.newPosition = newPosition;
useStudyStore.getState().logEvent("REORDER_ACTION_PROMPT", { action: action.name, previousPosition: previousPosition, newPosition: newPosition });
}
getActionDescription(actionEdge: ActionEdge): string {
const sourceEntity = useModelStore.getState().entityNodes.find(entity => entity.id === actionEdge.source);
const targetEntity = useModelStore.getState().entityNodes.find(entity => entity.id === actionEdge.target);
return `${sourceEntity?.data.name} ${actionEdge.data?.name} ${targetEntity?.data.name}`;
}
getPrompt(): string {
return this.getPromptV4();
}
getPromptV0(): string {
const actionToMove = useModelStore.getState().textActionMatches[this.previousPosition];
const actionRightAfterNewLocation = useModelStore.getState().textActionMatches[this.newPosition];
const text = useModelStore.getState().text;
const textToMove = text.slice(actionToMove.start, actionToMove.end);
let markedText = "";
if (this.newPosition < this.previousPosition) {
// Then we need to first mark the new position and then mark the text to be moved
markedText += text.slice(0, actionRightAfterNewLocation.start) + ` ${textToMove} ` + text.slice(actionRightAfterNewLocation.start, actionToMove.start);
markedText += text.slice(actionToMove.end, text.length);
} else {
// Then we need to first mark the text to be moved and then mark the new position
markedText += text.slice(0, actionToMove.start) + text.slice(actionToMove.end, actionRightAfterNewLocation.start);
markedText += ` ${textToMove} ` + text.slice(actionRightAfterNewLocation.start, text.length);
}
return `${markedText}\n\nRewrite the story so that it makes sense. Keep the same order of events.`;
}
getPromptV4(): string {
const actionEdges = useModelStore.getState().actionEdges;
const currentActionOrder = actionEdges.map((actionEdge, index) => `${(index+1)}) ` + this.getActionDescription(actionEdge)).join("\n");
if (this.newPosition < this.previousPosition) {
const actionToMove = actionEdges.splice(this.previousPosition, 1)[0];
actionEdges.splice(this.newPosition, 0, actionToMove);
} else {
const actionToMove = actionEdges[this.previousPosition];
actionEdges.splice(this.newPosition, 0, actionToMove);
actionEdges.splice(this.previousPosition, 1)[0];
}
const newActionOrder = actionEdges.map((actionEdge, index) => `${(index+1)}) ` + this.getActionDescription(actionEdge)).join("\n");
const text = useModelStore.getState().text;
useModelStore.getState().setActionEdges([...actionEdges]);
return `${text}\n\nIn this story, the current order of actions is:\n${currentActionOrder}\n\nRewrite the story to EXACTLY follow this new order:\n${newActionOrder}`;
}
getPromptV3() : string {
const actionToMove = useModelStore.getState().actionEdges[this.previousPosition];
const actionToMoveSourceEntity = useModelStore.getState().entityNodes.find(entity => entity.id === actionToMove.source);
const actionToMoveTargetEntity = useModelStore.getState().entityNodes.find(entity => entity.id === actionToMove.target);
const actionRightAfterNewLocation = useModelStore.getState().actionEdges[this.newPosition];
const actionRightAfterNewLocationSourceEntity = useModelStore.getState().entityNodes.find(entity => entity.id === actionRightAfterNewLocation.source);
const actionRightAfterNewLocationTargetEntity = useModelStore.getState().entityNodes.find(entity => entity.id === actionRightAfterNewLocation.target);
let locationInformation = `before "${actionRightAfterNewLocationSourceEntity?.data.name}" ${actionRightAfterNewLocation.data?.name} "${actionRightAfterNewLocationTargetEntity?.data.name}"`;
if (this.newPosition > 0) {
const actionRightBeforeNewLocation = useModelStore.getState().actionEdges[this.newPosition-1];
const actionRightBeforeNewLocationSourceEntity = useModelStore.getState().entityNodes.find(entity => entity.id === actionRightBeforeNewLocation.source);
const actionRightBeforeNewLocationTargetEntity = useModelStore.getState().entityNodes.find(entity => entity.id === actionRightBeforeNewLocation.target);
locationInformation += ` and after "${actionRightBeforeNewLocationSourceEntity?.data.name}" ${actionRightBeforeNewLocation.data?.name} "${actionRightBeforeNewLocationTargetEntity?.data.name}"`;
}
const actionToMoveDescription = `"${actionToMoveSourceEntity?.data.name}" ${actionToMove.data?.name} "${actionToMoveTargetEntity?.data.name}"`;
const text = useModelStore.getState().text;
return `${text}\n\nRewrite the story to move the action ${actionToMoveDescription} so that it happens ${locationInformation}.`;
}
getPromptV2() : string {
const actionToMove = useModelStore.getState().actionEdges[this.previousPosition];
const actionToMoveSourceEntity = useModelStore.getState().entityNodes.find(entity => entity.id === actionToMove.source);
const actionToMoveTargetEntity = useModelStore.getState().entityNodes.find(entity => entity.id === actionToMove.target);
const actionRightAfterNewLocation = useModelStore.getState().actionEdges[this.newPosition];
const actionRightAfterNewLocationSourceEntity = useModelStore.getState().entityNodes.find(entity => entity.id === actionRightAfterNewLocation.source);
const actionRightAfterNewLocationTargetEntity = useModelStore.getState().entityNodes.find(entity => entity.id === actionRightAfterNewLocation.target);
const text = useModelStore.getState().text;
return `${text}\n\nRewrite the story so that "${actionToMoveSourceEntity?.data.name}" ${actionToMove.data?.name} "${actionToMoveTargetEntity?.data.name}" ` +
`happens right BEFORE "${actionRightAfterNewLocationSourceEntity?.data.name}" ${actionRightAfterNewLocation.data?.name} "${actionRightAfterNewLocationTargetEntity?.data.name}"`;
}
getPromptV1() : string {
// For some reason, this idea for the prompt does not want to work
// Idea is to mark the text with the passage that has to be moved and where it should be moved to
const actionToMove = useModelStore.getState().textActionMatches[this.previousPosition];
const actionRightAfterNewLocation = useModelStore.getState().textActionMatches[this.newPosition];
const text = useModelStore.getState().text;
let markedText = "";
if (this.newPosition < this.previousPosition) {
// Then we need to first mark the new position and then mark the text to be moved
markedText += text.slice(0, actionRightAfterNewLocation.start) + " LOCATION " + text.slice(actionRightAfterNewLocation.start, actionToMove.start);
markedText += " ACTION " + text.slice(actionToMove.end, text.length);
} else {
// Then we need to first mark the text to be moved and then mark the new position
markedText += text.slice(0, actionToMove.start) + " ACTION " + text.slice(actionToMove.end, actionRightAfterNewLocation.start);
markedText += " LOCATION " + text.slice(actionRightAfterNewLocation.start, text.length);
}
return `${markedText}\n\nTEXT_TO_MOVE: ${text.slice(actionToMove.start, actionToMove.end)}\n\n` +
`Rewrite the story so that ACTION is moved to LOCATION `;
}
execute(): void {
super.execute();
}
}
@@ -0,0 +1,37 @@
import { useStudyStore } from "../../../study/StudyModel";
import { ActionEdge, useModelStore } from "../../Model";
import { TextEditPrompt } from "./TextEditPrompt";
export class RewriteFromVisual extends TextEditPrompt {
constructor() {
super();
useStudyStore.getState().logEvent("REWRITE_FROM_VISUAL_PROMPT", {});
}
getPrompt(): string {
const entities = useModelStore.getState().entityNodes.map(e => e.data.name).join(", ");
const getActionDescription = (actionEdge: ActionEdge) => {
const sourceEntity = useModelStore.getState().entityNodes.find(entity => entity.id === actionEdge.source);
const targetEntity = useModelStore.getState().entityNodes.find(entity => entity.id === actionEdge.target);
return `${sourceEntity?.data.name} ${actionEdge.data?.name} ${targetEntity?.data.name} [${actionEdge.data?.sourceLocation}]`;
}
const actionEdges = useModelStore.getState().actionEdges;
const currentActionOrder = actionEdges.map((actionEdge, index) => `${(index+1)}) ` + getActionDescription(actionEdge)).join("\n");
return `Write a simple and short story involving these characters: ${entities}. The story should follow this sequence of events [location of the scene indicated in brackets]: ${currentActionOrder}`;
}
onPartialResult(result: string): void {
useModelStore.getState().setTextState([{children: [{text: result}]}], true, false);
}
canBeExecuted(): boolean {
// Can only be executed when there are at least some entities and actions
return useModelStore.getState().entityNodes.length > 0;
}
}
@@ -0,0 +1,55 @@
import { useModelStore } from "../../Model";
import { TextUtils } from "../../TextUtils";
import { TextEditPrompt } from "./TextEditPrompt";
export abstract class TargettedTextEditPrompt extends TextEditPrompt {
splittedText: {precedingText: string, textToModify: string, followingText: string} | null;
constructor() {
super();
this.splittedText = null;
}
isTargetted(): boolean {
const actionSelection = useModelStore.getState().filteredActionsSegment;
return actionSelection !== null;
}
splitTextBasedOnSelection(): {precedingText: string, textToModify: string, followingText: string} | null {
const actionSelection = useModelStore.getState().filteredActionsSegment;
if (actionSelection) {
const firstActionMatch = useModelStore.getState().textActionMatches[actionSelection.start];
const lastActionMatch = useModelStore.getState().textActionMatches[actionSelection.end];
const precedingText = useModelStore.getState().text.slice(0, firstActionMatch.start);
const textToModify = useModelStore.getState().text.slice(firstActionMatch.start, lastActionMatch.end);
const followingText = useModelStore.getState().text.slice(lastActionMatch.end);
return {precedingText, textToModify, followingText};
}
return null;
}
getPrompt(): string {
this.splittedText = this.splitTextBasedOnSelection();
if (this.splittedText) {
return this.getTargettedPrompt(this.splittedText.precedingText, this.splittedText.textToModify, this.splittedText.followingText);
}
return this.getGlobalPrompt(useModelStore.getState().text);
}
reconstructResult(result: string): string {
// Should be based on the splitted text at the time the prompt was executed
if (this.splittedText) {
return this.splittedText.precedingText + TextUtils.getFittingString(result, this.splittedText.textToModify) + this.splittedText.followingText;
}
return result;
}
abstract getTargettedPrompt(precedingText: string, textToModify: string, followingText: string): string
abstract getGlobalPrompt(text: string): string
}
@@ -0,0 +1,56 @@
import { useHistoryModelStore } from "../../HistoryModel";
import { useModelStore } from "../../Model";
import { useViewModelStore } from "../../ViewModel";
import { VisualRefresher } from "../textExtractors/VisualRefresher";
import { TextPrompt } from "../utils/TextPrompt";
export abstract class TextEditPrompt {
temporaryResult: string = '';
onResult(result: string): void {
this.temporaryResult = this.reconstructResult(result);
}
execute(): void {
if (this.canBeExecuted()) {
const prompt = new TextPrompt({prompt: this.getPrompt()})
prompt.onPartialResponse = (result) => this.onPartialResult(result.result);
prompt.execute().then((result) => {
if (result && this.isResultValid(result.result)) {
this.onResult(result.result);
}
this.finalize();
}
);
useViewModelStore.getState().setTextIsBeingEdited(true);
}
}
finalize(): void {
VisualRefresher.getInstance().refreshFromText(this.temporaryResult,
undefined,
() => {
useModelStore.getState().suggestNextTextChanges();
useModelStore.getState().setTextState([{children: [{text: this.temporaryResult}]}], true, false);
useModelStore.getState().setIsStale(false);
useHistoryModelStore.getState().addHistoryNode(useModelStore.getState())
useViewModelStore.getState().setTextIsBeingEdited(false);
});
}
abstract getPrompt(): string
reconstructResult(result: string): string {
return result;
}
isResultValid(result: string): boolean {
return true;
}
canBeExecuted(): boolean {
return useModelStore.getState().text.length > 0;
}
onPartialResult(result: string): void {}
}
@@ -0,0 +1,58 @@
import { z } from "zod";
import { CreateEntityNode } from "../../../view/entityActionView/EntityNodeComponent";
import { LayoutUtils } from "../../LayoutUtils";
import { EntityNode, useModelStore } from "../../Model";
import { JSONPrompt } from "../utils/JSONPrompt";
const ENTITY_SCHEMA = z.object({
entities: z.array(z.object({
name: z.string(),
emoji: z.string(),
properties: z.array(z.object({
name: z.string(),
value: z.number()
}))
}))
});
export function extractedEntitiesToNodeEntities(extractedData: z.infer<typeof ENTITY_SCHEMA>) : EntityNode[] {
return extractedData.entities.map((entity, index) => CreateEntityNode(entity, index));
}
export function EntitiesExtractor(text : string, center: {x: number, y: number}) : Promise<EntityNode[]> {
const prompt = text +
`\n\nExtract all the entities in this story.` +
`For each entity, extract its 'name', an emoji best visually describing the entity (e.g., use the emoji of a person if it is a person but avoid reusing the same emojis),` +
`and properties about the entity, if any (no more than 3). ` +
`Properties have to be adjectives describing the entity and their value should represent the intensity of the property (on a scale from 1 to 10).`
const entityExtractor = new JSONPrompt({ prompt: prompt}, ENTITY_SCHEMA)
useModelStore.getState().setEntityNodes([]);
entityExtractor.onPartialResponse = (partialResult) => {
const newEntities = extractedEntitiesToNodeEntities(partialResult.result);
const oldEntities = useModelStore.getState().entityNodes;
// Reuse the position of the entities that already existed
const entities = newEntities.map((newEntity) => {
const oldEntity = oldEntities.find(e => e.data.name === newEntity.data.name);
if (oldEntity && oldEntity.position) newEntity.position = oldEntity.position;
if (oldEntity && oldEntity.measured) newEntity.measured = oldEntity.measured;
return newEntity;
});
useModelStore.getState().setEntityNodes(entities);
LayoutUtils.optimizeNodeLayout("entity", entities, useModelStore.getState().setEntityNodes, {x: center.x, y: center.y}, 120);
}
return new Promise((resolve, reject) => {
entityExtractor.execute().then((result) => {
console.log("Extracted entities:", result.result.entities);
resolve(useModelStore.getState().entityNodes);
})
});
}
@@ -0,0 +1,37 @@
import { z } from "zod";
import { ActionEdge, Entity, useModelStore } from "../../Model";
import { JSONPrompt } from "../utils/JSONPrompt";
import { CreateActionEdge } from "./SentenceActionsExtractor";
const PASSAGE_SCHEMA = z.object({
location: z.string(),
passage: z.string()
});
export function GetActionPassage(text: string, actionName: string, source: Entity, target: Entity): Promise<ActionEdge> {
const prompt = text +
`\n\nFor the action where ${source.name} ${actionName} ${target.name}, extract ` +
`the location of the action (you can use 'unknown' if the location cannot be inferred from the text).`+
`and the exact passage from the text that describes the action. The passage has to be extracted word-for-word from the text.`
const actionExtactor = new JSONPrompt({ prompt: prompt }, PASSAGE_SCHEMA)
const sourceNode = useModelStore.getState().entityNodes.find((node) => node.data.name === source.name);
const targetNode = useModelStore.getState().entityNodes.find((node) => node.data.name === target.name);
if (sourceNode && targetNode) {
return new Promise((resolve, reject) => {
actionExtactor.execute().then((result) => {
console.log("Actions extracted", result);
const actionEdge = CreateActionEdge({
name: actionName, source: source.name, target: target.name, location: result.result.location}, result.result.passage, sourceNode, targetNode);
resolve(actionEdge);
})
});
}
return Promise.reject("Source or target entity not found");
}
@@ -0,0 +1,42 @@
import { z } from "zod";
import { useModelStore } from "../../Model";
import { PromptResult } from "../utils/BasePrompt";
import { JSONPrompt } from "../utils/JSONPrompt";
export abstract class JSONExtractorPrompt<T> extends JSONPrompt<T> {
afterPartialResult: ((result: PromptResult<T>) => void) | null = null;
afterFinalResult: ((result: PromptResult<T>) => void) | null = null;
constructor() {
super({prompt: ""}, null as any);
}
abstract getPrompt(): string
abstract getJSONSchema(): z.ZodType<T>
abstract onPartialResult(result: PromptResult<T>): void
execute(): Promise<PromptResult<T>> {
if (useModelStore.getState().text.length > 0) {
this.prompt = {prompt: this.getPrompt()};
this.schema = this.getJSONSchema();
this.onPartialResponse = (result) => {
this.onPartialResult(result);
if (this.afterPartialResult) {
this.afterPartialResult(result);
}
};
return super.execute().then((result) => {
if (this.afterFinalResult) {
this.afterFinalResult(result);
}
this.finalize();
return result;
});
}
return new Promise<PromptResult<T>>((resolve, reject) => {resolve({result: [] as any})});
}
finalize(): void {}
}
@@ -0,0 +1,57 @@
import { z } from "zod";
import { CreateLocatioNode } from "../../../view/locationView/LocationNodeComponent";
import { LayoutUtils } from "../../LayoutUtils";
import { LocationNode, useModelStore } from "../../Model";
import { JSONPrompt } from "../utils/JSONPrompt";
const LOCATION_SCHEMA = z.object({
locations: z.array(z.object({
name: z.string(),
emoji: z.string(),
/*properties: z.array(z.object({
name: z.string(),
value: z.number()
}))*/
}))
});
export function extractedLocationsToNodeLocations(extractedData: z.infer<typeof LOCATION_SCHEMA>) : LocationNode[] {
return extractedData.locations.map((location, index) => CreateLocatioNode(location, index));
}
export function LocationExtractor(text : string, center: {x: number, y: number}) : Promise<LocationNode[]> {
const prompt = text +
`\n\nExtract all the main locations visited by the characters in this story.` +
`For each location, extract its 'name' and an emoji best visually representing the location`
const locationExtractor = new JSONPrompt({ prompt: prompt}, LOCATION_SCHEMA)
useModelStore.getState().setLocationNodes([]);
locationExtractor.onPartialResponse = (partialResult) => {
const newLocations = extractedLocationsToNodeLocations(partialResult.result);
const oldLocations = useModelStore.getState().locationNodes;
// Reuse the position of the locations that already existed
const locations = newLocations.map((newLocation) => {
const oldLocation = oldLocations.find(e => e.data.name === newLocation.data.name);
if (oldLocation && oldLocation.position) newLocation.position = oldLocation.position;
if (oldLocation && oldLocation.measured) newLocation.measured = oldLocation.measured;
return newLocation;
});
useModelStore.getState().setLocationNodes(locations);
LayoutUtils.optimizeNodeLayout("location", locations, useModelStore.getState().setLocationNodes, {x: center.x, y: center.y}, 120);
}
return new Promise((resolve, reject) => {
locationExtractor.execute().then((result) => {
console.log("Extracted locations:", result.result);
resolve(useModelStore.getState().locationNodes);
})
});
}
@@ -0,0 +1,189 @@
import { MarkerType } from "@xyflow/react";
import { z } from "zod";
import { CreateEntityNode } from "../../../view/entityActionView/EntityNodeComponent";
import { CreateLocatioNode } from "../../../view/locationView/LocationNodeComponent";
import { ActionEdge, EntityNode, LocationNode, useModelStore } from "../../Model";
import { PromptResult } from "../utils/BasePrompt";
import { JSONExtractorPrompt } from "./JSONExtractorPrompt";
const SCHEMA = z.object({
actions: z.array(z.object({
name: z.string(),
source: z.string(),
target: z.string(),
location: z.string()
}))
});
export function CreateActionEdge(action: z.infer<typeof SCHEMA>["actions"][0], passage: string, source: EntityNode, target: EntityNode): ActionEdge {
return {
id: "action-" + source.id + "-" + action.name + "-" + target.id,
type: "actionEdge",
label: action.name,
sourceHandle: action.source === action.target ? "l" : "b",
targetHandle: action.source === action.target ? "r" : "t",
animated: true,
markerEnd: { type: MarkerType.ArrowClosed, width: 25, height: 25},
source: source.id,
target: target.id,
data: { name: action.name, passage: passage, sourceLocation: action.location, targetLocation: action.location }
}
}
export function extractedActionsToEdgeActions(extractedData: z.infer<typeof SCHEMA>, passage: string, entities: EntityNode[]) : ActionEdge[] {
const edges: ActionEdge[] = [];
// Turn the entities into a dictionary to make it easy to fetch
const entitiesDict: {[key: string]: EntityNode} = {};
entities.forEach((entity) => {
entitiesDict[entity.data.name.toLowerCase()] = entity;
});
extractedData.actions.forEach((action, index) => {
if (action.source.toLowerCase() in entitiesDict && action.target.toLowerCase() in entitiesDict) {
// Figure out the best handles to use based on the position of the source and target entities
const sourceEntity = entitiesDict[action.source.toLowerCase()];
const targetEntity = entitiesDict[action.target.toLowerCase()];
let sourceHandle = sourceEntity.position.y < targetEntity.position.y ? 'b' : 't';
let targetHandle = sourceEntity.position.y < targetEntity.position.y ? 't' : 'b';
if (Math.abs(sourceEntity.position.y - targetEntity.position.y) < 20) {
sourceHandle = sourceEntity.position.x < targetEntity.position.x ? 'r' : 'l';
targetHandle = sourceEntity.position.x < targetEntity.position.x ? 'l' : 'r';
}
edges.push(CreateActionEdge(action, passage, sourceEntity, targetEntity));
}
});
return edges;
}
export function extractedActionsToLocations(extractedData: z.infer<typeof SCHEMA>) : LocationNode[] {
const locations = [...new Set(extractedData.actions.map((action) => action.location))];
return locations.map((location, index) => CreateLocatioNode({name: location, emoji: ""}, index));
}
export class SentenceActionsExtractor extends JSONExtractorPrompt<z.infer<typeof SCHEMA>> {
textBefore: string;
textToExtract: string;
textAfter: string;
entities: EntityNode[];
onUpdate: (() => void) | null = null;
constructor(entities: EntityNode[], textBefore: string, textToExtract: string, textAfter: string) {
super();
this.entities = entities;
this.textBefore = textBefore;
this.textToExtract = textToExtract;
this.textAfter = textAfter;
}
getPrompt(): string {
const entitiesStr = this.entities.map(e => e.data.name).join(", ");
const locationsStr = useModelStore.getState().locationNodes.map(e => e.data.name).join(", ");
return (this.textBefore.length === 0 ? "" : `BEFORE: ${this.textBefore}\n\n`) + //AFTER: ${this.textAfter}\n\n` +
`TEXT: ${this.textToExtract}\n\n` +
`Extract the actions done by the characters in TEXT and only the actions in TEXT. Do not extract the actions from BEFORE. ` +
`Only consider actions that are happening exactly at the moment of TEXT, ignore memories etc. ` +
`If there are no actions fulfilling these criterias in TEXT, then return an empty array. ` +
`Source and target should be characters from this list: ${entitiesStr}. ` +
`Here are some possible locations but there might be others: ${locationsStr}. ` +
`If an action is done by a character to itself, then the source and target character should be the same. ` +
`For each action, extract the 'name' of the action (no more than 2 words), ` +
`the source character (the character doing the action) and the target character (the character targetted by the action)`+
`, and the location of the action (you can use 'unknown' if the location cannot be inferred from the text).`
}
getJSONSchema(): z.ZodType<z.infer<typeof SCHEMA>> {
return SCHEMA;
}
onPartialResult(partialResult: PromptResult<z.infer<typeof SCHEMA>>): void {
const actionEdges = useModelStore.getState().actionEdges;
const entities = useModelStore.getState().entityNodes;
const locations = useModelStore.getState().locationNodes;
// Remove some actions that we probably do not care about / are mistakes
partialResult.result.actions = partialResult.result.actions.filter((action) => {
if (action.source === "unknown") return false;
// Sometimes the action being extracted comes from "BEFORE" instead of from "TEXT"
const words = action.name.toLowerCase().split(" ");
const isInBefore = words.some((word) => this.textBefore.toLowerCase().includes(word));
const isInText = words.some((word) => this.textToExtract.toLowerCase().includes(word));
if (!isInText && isInBefore) {
console.log("Ignoring action because it does not seem to be from the passage: ", {action: action, passage: this.textToExtract});
return false; // Definitely not an action from TEXT
}
return true;
});
// Clean up some common issues with the extracted data
partialResult.result.actions = partialResult.result.actions.map((action) => {
return {
name: action.name.trim(),
source: action.source.trim(),
target:["unknown", "itself", "himself", "herself", "themselves", "it", "he", "she", "they", action.location.toLowerCase()].includes(action.target.toLowerCase()) ? action.source.trim() : action.target.trim(),
location: action.location.trim()
}
});
// First, only consider the new actions
const newActions = partialResult.result.actions.filter((action) => {
if (action.name === "" || action.source === "" || action.target === "" || action.location === "") return false;
for (const existingAction of actionEdges) {
if (existingAction.data!.name === action.name) {
const sourceEntity = entities.find((entity) => entity.id === existingAction.source);
const targetEntity = entities.find((entity) => entity.id === existingAction.target);
if (sourceEntity && sourceEntity.data.name.toLowerCase() === action.source.toLowerCase() &&
targetEntity && targetEntity.data.name.toLowerCase() === action.target.toLowerCase()) {
return false;
}
}
}
return true;
});
if (newActions.length > 0) {
// Are new entities being introduced?
const newEntities = newActions.map((action) => action.source).concat(newActions.map((action) => action.target)).filter((entityName) => !entities.find((entity) => entity.data.name.toLowerCase() === entityName.toLowerCase()));
newEntities.forEach((entityName) => {
const newEntity = CreateEntityNode({name: entityName, emoji: "", properties: []}, entities.length);
entities.push(newEntity);
console.log("New entity: ", entityName, "because of actions: ", newActions);
//TODO: Get an emoji as well as some properties for that new entity
});
if (newEntities.length > 0) useModelStore.getState().setEntityNodes(entities);
// Are new locations being introduced?
const locationsMentioned = newActions.map((action) => action.location);
const newLocations = locationsMentioned.filter((locationName) => !locations.find((location) => location.data.name.toLowerCase() === locationName.toLowerCase()));
newLocations.forEach((locationName) => {
const newLocation = CreateLocatioNode({name: locationName, emoji: ""}, locations.length);
locations.push(newLocation);
});
if (newLocations.length > 0) useModelStore.getState().setLocationNodes(locations);
// Finally we set the actions
const newActionEdges = extractedActionsToEdgeActions({actions: newActions}, this.textToExtract, useModelStore.getState().entityNodes);
const allActions = [...actionEdges, ...newActionEdges];
const text = this.textBefore + this.textToExtract + this.textAfter;
// Sort the actions based on their position in the text
allActions.sort((a, b) => {
return text.indexOf(a.data!.passage) - text.indexOf(b.data!.passage);
});
useModelStore.getState().setActionEdges(allActions);
if (this.onUpdate) this.onUpdate();
}
}
}
@@ -0,0 +1,114 @@
import { LayoutUtils } from "../../LayoutUtils";
import { useModelStore } from "../../Model";
import { ParallelPrompts } from "../utils/ParallelPrompts";
import { SentenceActionsExtractor } from "./SentenceActionsExtractor";
let VisualRefresherInstance : VisualRefresher | null = null;
export class VisualRefresher {
previousText: string;
onUpdate: () => void;
onRefreshDone: () => void;
private constructor() {
this.previousText = "";
this.onUpdate = () => {};
this.onRefreshDone = () => {};
}
public static getInstance() {
if (!VisualRefresherInstance) {
VisualRefresherInstance = new VisualRefresher();
}
return VisualRefresherInstance;
}
reset() {
this.previousText = "";
}
clearInvalidActions(text: string) {
const actionEdges = useModelStore.getState().actionEdges;
const newActionEdges = actionEdges.filter((actionEdge) => text.includes(actionEdge.data!.passage));
useModelStore.getState().setActionEdges(newActionEdges);
}
clearInvalidEntities(text: string) {
const entityNodes = useModelStore.getState().entityNodes;
const newEntityNodes = entityNodes.filter((entityNode) => text.toLowerCase().includes(entityNode.data.name.toLowerCase()));
useModelStore.getState().setEntityNodes(newEntityNodes);
}
clearInvalidLocations() {
// We consider a location as invalid if it is not mentioned in any of the actions
const locationNodes = useModelStore.getState().locationNodes;
const actionEdges = useModelStore.getState().actionEdges;
const locationsInActions = [...new Set(actionEdges.map((actionEdge) => actionEdge.data!.sourceLocation).concat(actionEdges.map((actionEdge) => actionEdge.data!.targetLocation)))];
const newLocationNodes = locationNodes.filter((locationNode) => locationsInActions.includes(locationNode.data.name));
useModelStore.getState().setLocationNodes(newLocationNodes);
}
refreshFromText(text: string, onUpdate?: () => void, onFinished?: () => void) {
if (this.previousText === text) return;
// First we clear everything that became invalid since the new text
LayoutUtils.stopAllSimulations();
// We will be playing with the actions, the selection will become meaningless, better to clear it
//TODO: Be smart and only unselect the things that are actually becoming invalid. Otherwise try very hard to preserve the selection
useModelStore.getState().setSelectedNodes([]);
useModelStore.getState().setSelectedEdges([]);
useModelStore.getState().setFilteredActionsSegment(null, null);
useModelStore.getState().setHighlightedActionsSegment(null, null);
this.clearInvalidActions(text);
// Loop over the sentences in the text by finding the index position of the periods
const regex = /[^.!?]+[.!?]+/g;
let result;
let sentences : {start: number, end: number, text: string}[] = [];
while ( (result = regex.exec(text)) ) {
const startIdx = result.index;
const endIdx = result.index + result[0].length;
const sentenceStr = result[0].replace(/^\s+|\s+$/g, '');
if (sentenceStr.length < 20 && sentences.length > 0) { // Arbitrary threshold just to detect very short sentences
sentences[sentences.length - 1].end = endIdx;
sentences[sentences.length - 1].text = text.substring(sentences[sentences.length - 1].start, endIdx).replace(/^\s+|\s+$/g, '');
} else {
sentences.push({start: startIdx, end: endIdx, text: sentenceStr});
}
}
// Only bother updating the sentences that were not already in the previous text
sentences = sentences.filter((sentence) => !this.previousText.includes(sentence.text));
// Now we extract the actions from each sentences
const actionPromises = sentences.map((sentence) => {
const entities = useModelStore.getState().entityNodes;
const prompt = new SentenceActionsExtractor(entities, text.substring(0, sentence.start), sentence.text, text.substring(sentence.end, text.length));
prompt.onUpdate = () => {
if (onUpdate) onUpdate();
this.onUpdate();
}
return prompt
});
new ParallelPrompts(actionPromises).execute().then((results) => {
const actions = useModelStore.getState().actionEdges.map((actionEdge) => {
const sourceEntity = useModelStore.getState().entityNodes.find(entity => entity.id === actionEdge.source);
const targetEntity = useModelStore.getState().entityNodes.find(entity => entity.id === actionEdge.target);
return {name: actionEdge.data?.name, source: sourceEntity?.data.name, target: targetEntity?.data.name, location: actionEdge.data?.sourceLocation, passage: actionEdge.data?.passage}
});
console.log("Extracted actions:", actions);
if (onFinished) onFinished();
this.onRefreshDone();
});
this.previousText = text;
}
}
+23
View File
@@ -0,0 +1,23 @@
export interface PromptResult<T> {
result: T;
}
export interface ExecutablePrompt {
prompt: string;
model?: string;
}
export interface MessageGPT {
role: "user" | "assistant" | "system",
content: string
}
export class BasePrompt<O> {
execute(): Promise<O> {
return new Promise<O>((resolve, reject) => {
resolve(null as any);
});
}
}
+103
View File
@@ -0,0 +1,103 @@
import { zodResponseFormat } from 'openai/helpers/zod';
import { ChatCompletionChunk } from "openai/resources/index.mjs";
import { Allow, parse } from "partial-json";
import { ZodObject, z } from "zod";
import { useStudyStore } from "../../../study/StudyModel";
import { openai } from "../../Model";
import { BasePrompt, ExecutablePrompt, PromptResult } from "./BasePrompt";
export class JSONPrompt<T> extends BasePrompt<PromptResult<T>> {
prompt: ExecutablePrompt;
schema: z.ZodType<T>;
optionalSchema: ZodObject<any> | null;
onPartialResponse: null | ((partialResult: PromptResult<T>) => void);
constructor(prompt: ExecutablePrompt, schema: z.ZodType<T>) {
super();
this.prompt = prompt;
this.schema = schema;
this.optionalSchema = null;
this.onPartialResponse = null;
}
getDefaultValue(field: z.ZodTypeAny): any {
if (field instanceof z.ZodString) {
return '';
} else if (field instanceof z.ZodNumber) {
return 0;
} else if (field instanceof z.ZodBoolean) {
return false;
} else {
// Default fallback for other types (e.g., ZodUnion, ZodEnum)
return null;
}
};
addMissingFields(partialResponse: any, schema: z.ZodType): any {
const emptyObject = (schema as any as z.ZodObject<any>).shape;
const filledData = Object.keys(emptyObject).reduce((acc, key) => {
if (emptyObject[key] instanceof z.ZodObject) {
acc[key] = this.addMissingFields(partialResponse[key] || {}, emptyObject[key]);
} else if (emptyObject[key] instanceof z.ZodArray) {
acc[key] = (partialResponse[key] || []).map((item: any) => this.addMissingFields(item, emptyObject[key].element));
} else {
acc[key] = partialResponse.hasOwnProperty(key) ? partialResponse[key] : this.getDefaultValue(emptyObject[key]);
}
return acc;
}, {} as Record<string, z.ZodTypeAny>);
return filledData;
}
partialParse(response: string): T | null {
try {
// Partial parse
let partialResponse = parse(response, ~Allow.STR);
// Try adding missing values to the partial response using sensible defaults
return this.schema.parse(this.addMissingFields(partialResponse, this.schema)); // Should add the missing fields
} catch (e) {
// Do nothing if we could not parse the partial response
/*if (e instanceof z.ZodError) {
console.log(e.issues);
}
console.error("Partial parse error for ", response, e);*/
}
return null;
}
execute(): Promise<PromptResult<T>> {
return new Promise<PromptResult<T>>((resolve, reject) => {
(async () => {
useStudyStore.getState().logEvent("PROMPT_TO_EXECUTE", { prompt: this.prompt.prompt });
const stream = await openai.chat.completions.create({
model: this.prompt.model || "gpt-4o-2024-08-06",
messages: [{ role: 'user', content: this.prompt.prompt }],
stream: true,
temperature: 0,
response_format: zodResponseFormat(this.schema, "response"),
});
let response = '';
for await (const chunk of stream) {
response += chunk.choices[0]?.delta?.content || '';
if (this.onPartialResponse) {
const partialResult = this.partialParse(response);
if (partialResult) {
this.onPartialResponse({ result: partialResult });
}
}
}
useStudyStore.getState().logEvent("PROMPT_EXECUTED", { prompt: this.prompt.prompt, response: response });
this.onPartialResponse = null; // Reset the partial response callback
resolve({ result: JSON.parse(response) as T }); // The parsing should now never fail thanks to the new API. So no need for trying to fix / retrying the request by feeding the error anymore
})();
});
}
}
@@ -0,0 +1,26 @@
import { BasePrompt } from "./BasePrompt";
export class ParallelPrompts<O> extends BasePrompt<O[]> {
prompts: BasePrompt<O>[];
constructor(prompts: BasePrompt<O>[]) {
super();
this.prompts = prompts;
}
async runSequentiallyWithDelay() {
const results = [];
for (const prompt of this.prompts) {
const result = await prompt.execute();
results.push(result);
//await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1 second
}
return results;
}
execute(): Promise<O[]> {
return this.runSequentiallyWithDelay();
//return Promise.all(this.prompts.map(prompt => prompt.execute()));
}
}
@@ -0,0 +1,30 @@
import { BasePrompt } from "./BasePrompt";
export class SequentialPrompts<O> extends BasePrompt<O[]> {
prompts: BasePrompt<O>[];
constructor(prompts: BasePrompt<O>[]) {
super();
this.prompts = prompts;
}
execute(): Promise<O[]> {
return new Promise<O[]>((resolve, reject) => {
let results: O[] = [];
let i = 0;
let next = () => {
if (i < this.prompts.length) {
this.prompts[i].execute().then((result) => {
results.push(result);
i++;
next();
});
} else {
resolve(results);
}
}
next();
});
}
}
+36
View File
@@ -0,0 +1,36 @@
import { openai } from "../../Model";
import { BasePrompt, ExecutablePrompt, PromptResult } from "./BasePrompt";
export class TextPrompt extends BasePrompt<PromptResult<string>> {
prompt: ExecutablePrompt;
onPartialResponse: null | ((partialResult : PromptResult<string>) => void);
constructor(prompt: ExecutablePrompt) {
super();
this.prompt = prompt;
this.onPartialResponse = null
}
execute(): Promise<PromptResult<string>> {
return new Promise<PromptResult<string>>((resolve, reject) => {
(async () => {
const stream = await openai.chat.completions.create({
model: this.prompt.model || "gpt-4o-2024-08-06",
messages: [{ role: 'user', content: this.prompt.prompt }],
temperature: 0,
stream: true,
});
let response = '';
for await (const chunk of stream) {
response += chunk.choices[0]?.delta?.content || '';
if (this.onPartialResponse) {
this.onPartialResponse({ result: response });
}
}
resolve({ result: response });
})();
});
}
}
+153
View File
@@ -0,0 +1,153 @@
import { Button, Textarea } from "@nextui-org/react";
import React, { useEffect, useMemo, useState } from "react";
import { FaTrashCan } from "react-icons/fa6";
import { IoSend } from "react-icons/io5";
import { PiOpenAiLogo } from "react-icons/pi";
import ReactMarkdown from 'react-markdown';
import { Transforms, createEditor } from "slate";
import { withHistory } from "slate-history";
import { Editable, Slate, withReact } from "slate-react";
import { openai, useModelStore } from "../model/Model";
import { useStudyStore } from "./StudyModel";
export interface MessageGPT {
role: "user" | "assistant" | "system",
content: string
}
export default function BaselineInterface(props: { children?: React.ReactNode }) {
const [textInputValue, setTextInputValue] = useState("");
const [gptMessages, setGptMessages] = useState<MessageGPT[]>([
]);
const messageDivRef = React.createRef<HTMLDivElement>();
const editor = useMemo(() => {
const instance = withReact(withHistory(createEditor()))
const { normalizeNode } = instance
instance.normalizeNode = entry => {
const [node, path] = entry
if (path.length === 0) { // Root node
const paragraphs = (node as any).children;
// Ensure that there is only one paragraph
if (paragraphs.length > 1) {
// Add a new line at the begining of the following paragraph
Transforms.insertText(instance, "\n", { at: { path: [1, 0], offset: 0 } })
Transforms.mergeNodes(instance, { at: [1] })
}
}
// Fall back to the original `normalizeNode` to enforce other constraints.
normalizeNode(entry)
}
return instance;
}, []);
const onMessageSend = () => {
const messages: MessageGPT[] = [...gptMessages, { content: textInputValue, role: 'user' }];
setGptMessages(messages);
setTextInputValue("");
useStudyStore.getState().logEvent("CHATGPT_PROMPTED", { prompt: textInputValue });
// Send the message to ChatGPT
(async () => {
const stream = await openai.chat.completions.create({
model: 'gpt-4o',
messages: messages,
stream: true,
});
const response: MessageGPT = { content: "", role: 'assistant' };
setGptMessages([...messages, response]);
for await (const chunk of stream) {
response.content += chunk.choices[0]?.delta?.content || '';
setGptMessages([...messages, response]);
}
useStudyStore.getState().logEvent("CHATGPT_RESPONDED", { response: response.content, fullHistory: gptMessages });
})();
}
useEffect(() => {
if (messageDivRef.current) {
// Always scroll to the bottom of the message
messageDivRef.current.scrollTop = messageDivRef.current.scrollHeight;
}
});
useEffect(() => {
editor.children = useModelStore.getState().textState;
editor.onChange();
}, [useModelStore.getState().textState]);
return (
<>
<div style={{ display: 'flex', flexDirection: 'row', height: '100vh', width: '100%', background: '#F2EEF0' }}>
{/* Text Editor Side */}
<div style={{ minWidth: 720, height: '100%', width: '70%', display: 'flex', flexDirection: 'row', justifyContent: 'center' }}>
<div id="mainTextField" className={"textEditor"} style={{ position: 'relative', background: 'white', width: 700, paddingTop: 60, marginTop: 20, paddingLeft: 50, paddingRight: 50, borderRadius: '2px', boxShadow: '0 0 10px rgba(0,0,0,0.1)', overflow: 'scroll' }}>
<Slate editor={editor} initialValue={[]}>
<Editable />
</Slate>
</div>
</div>
{/* ChatGPT Side */}
<div style={{ position: 'relative', width: '30%', background: 'white', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<Button variant="light" isIconOnly disabled={gptMessages.length === 0} style={{top: 5, right: 5, fontSize: 16, position: 'absolute'}} size="sm"
onClick={() => setGptMessages([])}
><FaTrashCan/></Button>
{/* Messages */}
<div ref={messageDivRef} style={{ maxWidth: 960, minWidth: 400, flexGrow: 1, display: 'flex', fontSize: 18, flexDirection: 'column', alignItems: 'center', justifyContent: 'start', width: '100%', overflow: 'scroll', marginTop: 20, paddingLeft: 20, paddingRight: 20 }}>
{gptMessages.map((message, index) => {
return (
<div key={index} style={{ display: 'flex', flexDirection: 'row', gap: 10, alignItems: 'start', marginBottom: 10, justifyContent: message.role === "user" ? "end" : 'start', width: '100%' }}>
{message.role === "assistant" && <div style={{ borderRadius: '50%', border: '1px solid #dddddd', padding: 5, marginTop: 10, fontSize: 25 }}><PiOpenAiLogo /></div>}
<div style={{ background: message.role === 'user' ? '#f4f4f4' : undefined, color: 'black', padding: 10, borderRadius: 5, whiteSpace: 'pre-wrap' }}>
{message.role === "assistant" && <ReactMarkdown>
{message.content}
</ReactMarkdown>}
{message.role === "user" && message.content}
</div>
</div>
)
})}
</div>
{ /* Text input */}
<div style={{ flexGrow: 0, position: 'relative', width: '80%', maxWidth: 960, marginBottom: 20 }}>
<Textarea
value={textInputValue}
onChange={(e) => setTextInputValue(e.target.value)}
style={{ fontSize: 20, paddingRight: 40, paddingTop: 8, paddingBottom: 8 }}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
onMessageSend();
e.preventDefault();
}
}}
minRows={1}
placeholder="Message ChatGPT" />
<Button isDisabled={textInputValue.length === 0} isIconOnly color={'primary'} style={{ position: 'absolute', right: 5, bottom: 5 }} size={'md'}
onClick={onMessageSend}
>
<IoSend />
</Button>
</div>
</div>
{props.children}
</div>
</>
)
}
+16
View File
@@ -0,0 +1,16 @@
import React from 'react';
import TextEditor from '../view/TextEditor';
export default function ReadingInterface(props: { children?: React.ReactNode }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', background: 'rgb(242, 238, 240)' }}>
<div style={{ display: 'flex', flexDirection: 'row', flexGrow: 1, height: '80%', alignItems: 'center', justifyContent: 'center' }}>
{props.children}
<TextEditor overlayOnHover={false} />
</div>
</div>
)
}
+244
View File
@@ -0,0 +1,244 @@
import readingVideo from '/videos/Reading.mp4';
import { dataTextA, textA } from './data/TextA';
import { dataTextF, textF } from './data/TextF';
import { dataTextG, textG } from './data/TextG';
import { StudyStep } from './WritingStudyTaskGenerator';
export class ReadingStudyTaskGenerator {
static generateSteps(participantId: number): StudyStep[] {
let steps : StudyStep[] = [];
const link = ``
// First, add the opening message with the link to complete the demographic survey
steps.push({
condition: "NOT_A_CONDITION",
task: "NOT_A_TASK",
type: "MESSAGE",
message: `Thank you for participating in this study. Please, first complete this survey questionnaire: <a target="_blank" href='${link}'>Demographic survey</a>. After completing the survey, return to this page to continue the study.`,
});
/*const latinSquare = [
[['C1', 'C2', 'S1', 'T2', 'S2', 'T1', 'F2', 'F1'], ['C2', 'T2', 'C1', 'T1', 'S1', 'F1', 'S2', 'F2']],
[['C2', 'T2', 'C1', 'T1', 'S1', 'F1', 'S2', 'F2'], ['T2', 'T1', 'C2', 'F1', 'C1', 'F2', 'S1', 'S2']],
[['T2', 'T1', 'C2', 'F1', 'C1', 'F2', 'S1', 'S2'], ['T1', 'F1', 'T2', 'F2', 'C2', 'S2', 'C1', 'S1']],
[['T1', 'F1', 'T2', 'F2', 'C2', 'S2', 'C1', 'S1'], ['F1', 'F2', 'T1', 'S2', 'T2', 'S1', 'C2', 'C1']],
[['F1', 'F2', 'T1', 'S2', 'T2', 'S1', 'C2', 'C1'], ['F2', 'S2', 'F1', 'S1', 'T1', 'C1', 'T2', 'C2']],
[['F2', 'S2', 'F1', 'S1', 'T1', 'C1', 'T2', 'C2'], ['S2', 'S1', 'F2', 'C1', 'F1', 'C2', 'T1', 'T2']],
[['S2', 'S1', 'F2', 'C1', 'F1', 'C2', 'T1', 'T2'], ['S1', 'C1', 'S2', 'C2', 'F2', 'T2', 'F1', 'T1']],
[['S1', 'C1', 'S2', 'C2', 'F2', 'T2', 'F1', 'T1'], ['C1', 'C2', 'S1', 'T2', 'S2', 'T1', 'F2', 'F1']],
[['C1', 'C2', 'S1', 'T2', 'S2', 'T1', 'F2', 'F1'], ['T2', 'T1', 'C2', 'F1', 'C1', 'F2', 'S1', 'S2']],
[['C2', 'T2', 'C1', 'T1', 'S1', 'F1', 'S2', 'F2'], ['T1', 'F1', 'T2', 'F2', 'C2', 'S2', 'C1', 'S1']],
[['T2', 'T1', 'C2', 'F1', 'C1', 'F2', 'S1', 'S2'], ['F1', 'F2', 'T1', 'S2', 'T2', 'S1', 'C2', 'C1']],
[['T1', 'F1', 'T2', 'F2', 'C2', 'S2', 'C1', 'S1'], ['F2', 'S2', 'F1', 'S1', 'T1', 'C1', 'T2', 'C2']]
];*/
const latinSquare = [
[['C1', 'S2', 'T2', 'F2'], ['S1', 'F1', 'C2', 'T1']],
[['S2', 'F2', 'C2', 'T2'], ['F1', 'T1', 'S1', 'C1']],
[['F2', 'T2', 'S2', 'C1'], ['T1', 'C2', 'F1', 'S1']],
[['T2', 'C2', 'F2', 'S2'], ['C1', 'S1', 'T1', 'F1']],
[['C2', 'S2', 'T2', 'F2'], ['F1', 'T1', 'S1', 'C1']],
[['S2', 'F2', 'C2', 'T2'], ['T1', 'C1', 'F1', 'S1']],
[['F2', 'T2', 'S2', 'C2'], ['C1', 'S1', 'T1', 'F1']],
[['T2', 'C2', 'F2', 'S2'], ['S1', 'F1', 'C1', 'T1']],
[['C2', 'S1', 'T1', 'F1'], ['T2', 'C1', 'F2', 'S2']],
[['S1', 'F1', 'C1', 'T1'], ['C2', 'S2', 'T2', 'F2']],
[['F1', 'T1', 'S1', 'C2'], ['S2', 'F2', 'C1', 'T2']],
[['T1', 'C1', 'F1', 'S1'], ['F2', 'T2', 'S2', 'C2']],
[['C1', 'S1', 'T1', 'F1'], ['C2', 'S2', 'T2', 'F2']],
[['S1', 'F1', 'C1', 'T1'], ['S2', 'F2', 'C2', 'T2']],
[['F1', 'T1', 'S1', 'C1'], ['F2', 'T2', 'S2', 'C2']],
[['T1', 'C1', 'F1', 'S1'], ['T2', 'C2', 'F2', 'S2']],
];
const order = latinSquare[(participantId-1) % latinSquare.length];
for (let conditionIdx = 0; conditionIdx < order.length; ++conditionIdx) {
const condition = (participantId % 2) !== conditionIdx ? "VISUALWRITING" : "BASELINE";
const text = conditionIdx === 0 ? {
startingState: { textState: [{ children: [{ text: textG }]}]},
hardcodedData: JSON.parse(JSON.stringify(dataTextG))
} : {
startingState: { textState: [{ children: [{ text: textF }]}]},
hardcodedData: JSON.parse(JSON.stringify(dataTextF))
};
const tasks = order[conditionIdx];
if (condition === "VISUALWRITING") {
// Add a video tutorial before the first visual writing task
steps.push({
type: "VIDEO",
task: "NOT_A_TASK",
condition: "VISUALWRITING",
saveData: false,
message: readingVideo,
});
steps.push({
type: "TEST_TOOL",
task: "NOT_A_TASK",
condition: "VISUALWRITING",
saveData: false,
instructions: [`Explore the tool. Make sure to test:\n- Hovering over some entities to see who they interact with\n- Hovering over the timeline to see the actions in chronological order\n- Displaying the locations and hovering over the timeline to see the movements`],
startingState: { textState: [{ children: [{ text: textA }]}]},
hardcodedData: JSON.parse(JSON.stringify(dataTextA))
});
steps.push({
type: "MESSAGE",
task: "NOT_A_TASK",
condition: "NOT_A_CONDITION",
saveData: false,
message: `Feel free to take a break.` ,
});
}
// Before the task, there should be an opportunity to read the text
steps.push({
type: "TASK",
task: "READING_BEFORE_PLANNING",
condition: "BASELINE",
saveData: false,
instructions: [`Please read the text in its entirety before clicking next`],
...text
});
tasks.forEach((taskId, idx) => {
const task = taskDictionary[taskId];
steps.push({
...task,
type: "TASK",
condition: condition,
saveData: false,
...text
});
steps.push({
type: "MESSAGE",
task: "NOT_A_TASK",
condition: "NOT_A_CONDITION",
saveData: true,
message: `Feel free to take a break.` ,
});
});
const tlx_url = ``
steps.push({
type: "MESSAGE",
task: "NOT_A_TASK",
condition: "NOT_A_CONDITION",
saveData: false,
message: `Please answer this questionnaire: <a href='${tlx_url}' target="_blank">Questionnaire</a>.` ,
});
}
//const url = "";
const url = ``
steps.push({
type: "MESSAGE",
task: "NOT_A_TASK",
condition: "VISUALWRITING",
saveData: true,
message: `Thank you for participating in this study!`,
});
return steps;
}
}
const taskDictionary: { [name: string]: StudyStep} = {
"C1": {
task: "PLANNING_CHARACTERS",
instructions: [
"Could characters be combined without changing the outcome of the story?",
"How did you accomplish this task?"
]
} as StudyStep,
"C2": {
task: "PLANNING_CHARACTERS",
instructions: [
"Is any character too passive?",
"How did you accomplish this task?"
]
} as StudyStep,
"S1": {
task: "PLANNING_SPACE",
instructions: [
"Are there any locations which could be removed?",
"How did you accomplish this task?"
]
} as StudyStep,
"S2": {
task: "PLANNING_SPACE",
instructions: [
"Are there moments where the spatial logic is broken or unclear?",
"How did you accomplish this task?"
]
} as StudyStep,
"T1": {
task: "PLANNING_TEMPORALITY",
instructions: [
"Is there a large gap between two actions that make the story progress (lull moment)?",
"How did you accomplish this task?"
]
} as StudyStep,
"T2": {
task: "PLANNING_TEMPORALITY",
instructions: [
"Is there a scene you think could take place later/earlier in the story?",
"How did you accomplish this task?"
]
} as StudyStep,
"F1": {
task: "PLANNING_FOCALIZATION",
instructions: [
"If told from another character's perspective, how would the story change?",
"How did you accomplish this task?"
]
} as StudyStep,
"F2": {
task: "PLANNING_FOCALIZATION",
instructions: [
"What does the main character mention that no other character would?",
"How did you accomplish this task?"
]
} as StudyStep,
} as any;
+158
View File
@@ -0,0 +1,158 @@
import { useState } from 'react';
import { Accordion, AccordionItem, Button, Card, CardBody, Popover, PopoverContent, PopoverTrigger, Slider } from '@nextui-org/react';
import { useModelStore } from '../model/Model';
import VisualWritingInterface from '../view/VisualWritingInterface';
import ReadingInterface from './ReadingInterface';
import { ReadingStudyTaskGenerator } from './ReadingStudyTaskGenerator';
import StudyMessage from './StudyMessage';
import { useStudyStore } from './StudyModel';
import StudyVideo from './StudyVideo';
import { WritingStudyTaskGenerator } from './WritingStudyTaskGenerator';
export default function StudyInterface() {
const nextStep = useStudyStore(state => state.nextStep);
const [showSlider, setShowSlider] = useState(false);
let participantId = useStudyStore(state => state.participantId);
let steps = useStudyStore(state => state.steps);
let stepId = useStudyStore(state => state.stepId);
const setSteps = useStudyStore(state => state.setSteps);
const setParticipantId = useStudyStore(state => state.setParticipantId);
const setStepId = useStudyStore(state => state.setStepId);
const setStudyType = useStudyStore(state => state.setStudyType);
const setIsReadOnly = useModelStore(state => state.setIsReadOnly);
const isReadOnly = useModelStore(state => state.isReadOnly);
const [instructionIndex, setInstructionIndex] = useState(0);
const isOutOfTime = useStudyStore(state => state.isOutOfTime);
const [sliderValue, setSliderValue] = useState(-1);
const [resetButtonTimestamp, setResetButtonTimestamp] = useState(0);
const [resetPopoverEnabled, setResetPopoverEnabled] = useState(false);
// Use URL parameters to generate the steps
const hashSplitted = window.location.hash.split("?");
const search = hashSplitted[hashSplitted.length-1]
const params = new URLSearchParams(search);
const pid = params.get('pid');
const pstepId = params.get('stepId');
const studyType = params.get('studyType');
if (participantId === -1 && pid) {
// @ts-ignore
window["model"] = useModelStore;
setParticipantId(participantId = parseInt(pid))
setStudyType(studyType === "READING" ? "READING" : "WRITING");
console.log("Study type: ", studyType);
if (studyType === "WRITING") {
setSteps(WritingStudyTaskGenerator.generateSteps(participantId))
setIsReadOnly(false);
} else {
setSteps(ReadingStudyTaskGenerator.generateSteps(participantId))
setIsReadOnly(true);
}
// In case of failure, we allow jumping to a specific step directly
if (pstepId) {
setStepId(stepId = parseInt(pstepId));
} else {
setStepId(stepId = 0);
}
}
const currentStep = steps[stepId];
if (participantId < 0 || !currentStep) {
return <StudyMessage content="Error: Make sure the URL is correct." />
}
if (currentStep.type === "MESSAGE") {
return (
<StudyMessage content={currentStep.message!} showNextButton={stepId + 1 < steps.length} />
)
} else if (currentStep.type === "VIDEO") {
return <StudyVideo video={currentStep.message!} />
}
const instructionCard = (<div style={{ position: 'absolute', left: 10, bottom: isReadOnly ? 10 : 60, userSelect: 'none', zIndex: 99999 }}>
<Card style={{ width: 350 }}>
<CardBody>
<Accordion isCompact defaultExpandedKeys={["1"]}>
<AccordionItem key="1" aria-label="Accordion 1" title="Instructions" style={{whiteSpace: 'pre-wrap'}}>
{!showSlider && currentStep.instructions![instructionIndex]}
</AccordionItem>
</Accordion>
{showSlider && <span style={{ width: '100%', marginTop: 20, marginBottom: 10, textAlign: 'center' }}>How succesful were you in accomplish these tasks</span>}
<div style={{ display: 'flex', flexDirection: 'row', gap: 10, alignItems: 'center' }}>
{showSlider && <Slider value={sliderValue} onChange={(v) => setSliderValue(v as number)} showSteps={true} minValue={1} maxValue={5} step={1} label={"Unsucessful"} getValue={() => "Successful"}
classNames={{
thumb: sliderValue < 0 ? "hidden" : "",
track: sliderValue < 0 ? "border-s-transparent" : "",
filler: sliderValue < 0 ? "hidden" : "",
}}
/>}
{ !showSlider && <Popover isOpen={resetPopoverEnabled} onClose={() => setResetPopoverEnabled(false)}>
<PopoverTrigger>
<Button variant={"light"} onPressStart={(e) => {
setResetButtonTimestamp(new Date().getTime());
}}
onPressEnd={(e) => {
if (new Date().getTime() - resetButtonTimestamp > 1000) {
useStudyStore.getState().logEvent("RESET_PRESSED");
useStudyStore.getState().resetStep();
} else {
setResetPopoverEnabled(true);
}
}}
>Reset</Button>
</PopoverTrigger>
<PopoverContent>
<p>Press for at least 1 second</p>
</PopoverContent>
</Popover>}
<Button isDisabled={showSlider && sliderValue < 0} color={isOutOfTime ? "danger" : undefined} style={{ flexGrow: 5 }} onClick={(e) => {
useStudyStore.getState().logEvent("NEXT_PRESSED");
if (instructionIndex + 1 < currentStep.instructions!.length) {
useStudyStore.getState().logEvent("SUBTASK_COMPLETED"/*, { finalState: useModelStore.getState(), text: document.getElementById("mainTextField")?.innerText }*/);
setInstructionIndex(instructionIndex + 1);
} else {
if (currentStep.type === "TASK" && !showSlider && currentStep.task !== "FREE_FORM" && currentStep.task !== "READING_BEFORE_PLANNING" && studyType === "WRITING") {
useStudyStore.getState().logEvent("TASK_COMPLETED"/*, { finalState: useModelStore.getState(), text: document.getElementById("mainTextField")?.innerText }*/);
setShowSlider(true);
} else {
useStudyStore.getState().logEvent("NEXT_PRESSED_WITH_RATING", { rating: sliderValue });
setShowSlider(false);
setInstructionIndex(0);
setSliderValue(-1);
nextStep();
}
}
}}>Next</Button>
</div>
</CardBody>
</Card>
</div>)
if (currentStep.condition === "BASELINE") {
return (
<>
<ReadingInterface>
{instructionCard}
</ReadingInterface>
</>
)
}
return (
<>
<VisualWritingInterface>
{instructionCard}
</VisualWritingInterface>
</>
)
}
+27
View File
@@ -0,0 +1,27 @@
import { Button, Card, CardBody } from "@nextui-org/react";
import { useStudyStore } from "./StudyModel";
export default function StudyMessage(props: { content: string, showNextButton?: boolean }) {
const nextStep = useStudyStore(state => state.nextStep);
return <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh', background: 'rgb(242, 238, 240)' }}>
<Card style={{ width: 500, padding: 10 }}>
<CardBody>
<div dangerouslySetInnerHTML={{__html: props.content}} />
{ props.showNextButton && <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'right' }}>
<Button style={{ marginTop: 20, width: 100 }} onClick={(e) => {
useStudyStore.getState().logEvent("NEXT_PRESSED");
nextStep();
}}>Next</Button>
</div> }
{ !props.showNextButton &&
<Button style={{ marginTop: 20}} onClick={(e) => {
// Download the data from localStorage (backup)
useStudyStore.getState().saveData(false, true);
}
}>Download data</Button>}
</CardBody>
</Card>
</div>
}
+172
View File
@@ -0,0 +1,172 @@
import { create } from 'zustand';
import { useHistoryModelStore } from '../model/HistoryModel';
import { useModelStore } from '../model/Model';
import { useViewModelStore } from '../model/ViewModel';
import { extractedEntitiesToNodeEntities } from '../model/prompts/textExtractors/EntitiesExtractor';
import { extractedLocationsToNodeLocations } from '../model/prompts/textExtractors/LocationsExtractor';
import { extractedActionsToEdgeActions } from '../model/prompts/textExtractors/SentenceActionsExtractor';
import { VisualRefresher } from '../model/prompts/textExtractors/VisualRefresher';
import { StudyStep } from './WritingStudyTaskGenerator';
const TIMEOUT_TIME = 4 * 60 * 1000; // 4 min
let previousTimeout: NodeJS.Timeout | null = null;
/**
* Model
**/
interface StudyModelState {
participantId: number,
stepId: number,
steps: StudyStep[],
isDataSaved: boolean,
csvData: string,
isOutOfTime: boolean,
studyType: "READING" | "WRITING"
}
interface StudyModelAction {
reset: () => void,
resetStep: () => void,
setParticipantId: (participantId: number) => void,
setStepId: (stepId: number) => void,
setSteps: (steps: StudyStep[]) => void,
nextStep: () => void,
saveData: (clear?: boolean, fromCookie?: boolean) => void,
logEvent: (eventName: string, parameters?: any) => void,
setIsDataSaved: (isDataSaved: boolean) => void,
setStudyType: (studyType: "READING" | "WRITING") => void
}
const CSV_HEADER = "Timestamp,ParticipantId,StepId,StepType,Task,Condition,Event,Parameters"
const initialState: StudyModelState = {
participantId: -1,
stepId: -1,
studyType: "READING",
isDataSaved: true,
csvData: CSV_HEADER,
steps: [],
isOutOfTime: false
}
export const useStudyStore = create<StudyModelState & StudyModelAction>()((set, get) => ({
...initialState,
reset: () => set((state) => ({ ...initialState })),
setParticipantId: (participantId: number) => set((state) => ({ participantId: participantId })),
resetStep: () => {
useModelStore.getState().reset();
useViewModelStore.getState().reset();
useHistoryModelStore.getState().reset();
useModelStore.getState().setIsReadOnly(get().studyType === "READING");
// Set the new text fields
const step = JSON.parse(JSON.stringify(get().steps[get().stepId])) as StudyStep;
if (step.startingState) {
useModelStore.getState().setTextState(step.startingState.textState as any, true, false);
useModelStore.getState().setIsStale(false);
VisualRefresher.getInstance().previousText = useModelStore.getState().text;
VisualRefresher.getInstance().onUpdate();
}
if (step.hardcodedData) {
const entityNodes = extractedEntitiesToNodeEntities(step.hardcodedData);
const locationNodes = extractedLocationsToNodeLocations(step.hardcodedData);
const actionEdges = step.hardcodedData.actions.map(h => extractedActionsToEdgeActions({actions: [h]}, h.passage, entityNodes)).flat();
useModelStore.getState().setEntityNodes(entityNodes);
useModelStore.getState().setLocationNodes(locationNodes);
useModelStore.getState().setActionEdges(actionEdges);
}
},
setStepId: (stepId: number) => {
set((state) => ({ stepId: stepId }))
if (get().stepId < get().steps.length) {
const newStepId = get().stepId;
const step = get().steps[newStepId];
get().resetStep();
if (get().isDataSaved && step.saveData && get().csvData.length > 0) {
get().saveData();
}
// Set up a timeout of 3min before setting the isOutOfTime flag
if (previousTimeout !== null) {
// Clear any previous timeout
clearTimeout(previousTimeout);
previousTimeout = null;
}
set((state) => ({ isOutOfTime: false }));
if (step.type === "TASK") {
previousTimeout = setTimeout(() => {
useStudyStore.setState((state) => ({ isOutOfTime: true }));
useStudyStore.getState().logEvent("TIMEOUT_REACHED");
}, TIMEOUT_TIME);
}
useHistoryModelStore.getState().reset();
}
// Change the URL to make it match if it is not already the case
const hashSplitted = window.location.hash.split("?");
const search = hashSplitted[hashSplitted.length - 1]
const params = new URLSearchParams(search);
if (params.get("stepId") !== get().stepId.toString()) {
params.set("stepId", stepId.toString());
window.location.hash = hashSplitted.slice(0, hashSplitted.length - 1).join("?") + "?" + params.toString();
}
},
setSteps: (steps: StudyStep[]) => set((state) => ({ steps: steps })),
nextStep: () => {
if (get().stepId + 1 < get().steps.length) {
get().setStepId(get().stepId + 1);
}
},
setStudyType: (studyType: "READING" | "WRITING") => set((state) => ({ studyType: studyType })),
logEvent(eventName: string, parameters?: any) {
//console.log("LOG:", eventName, parameters)
/*if (get().isDataSaved) {
let strParams = parameters ? btoa(unescape(encodeURIComponent(JSON.stringify(parameters)))) : "";
const currentStep = get().steps[get().stepId];
if (currentStep) {
const values = [Date.now(), get().participantId, get().stepId, currentStep.type, currentStep.task, currentStep.condition, eventName, strParams];
set((state) => ({ csvData: state.csvData + "\n" + values.join(",") }));
const cookieName = "studyData_" + get().participantId;
let cookieValue = localStorage.getItem(cookieName) || CSV_HEADER;
cookieValue += "\n" + values.join(",");
localStorage.setItem(cookieName, cookieValue);
}
}*/
},
saveData(clear = true, fromCookie = false): void {
/*if (get().isDataSaved) {
const element = document.createElement('a');
element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(fromCookie ? localStorage.getItem("studyData_" + get().participantId) || "" : get().csvData));
element.setAttribute('download', "P" + get().participantId + "_" + (fromCookie ? "FULL" : get().stepId) + ".csv");
element.style.display = 'none';
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
if (clear) {
set((state) => ({ csvData: "" }))
}
}*/
},
setIsDataSaved: (isDataSaved) => set((state) => ({ isDataSaved: isDataSaved })),
}))
//
+19
View File
@@ -0,0 +1,19 @@
import { Button } from "@nextui-org/react";
import { useStudyStore } from "./StudyModel";
export default function StudyVideo(props: { video: string }) {
const nextStep = useStudyStore(state => state.nextStep);
return <div style={{ display: 'flex', flexDirection: 'column', width: '100%', height: '100vh', alignItems: 'center', justifyContent: 'center' }}>
<video width={'80%'} height={'80%'} controls>
<source src={props.video} type="video/mp4" />
Your browser does not support the video tag.
</video>
<div style={{ width: '80%', display: "flex", justifyContent: "right" }}>
<Button style={{ marginTop: 15, marginBottom: 40, padding: 20 }} onClick={(e) => {
useStudyStore.getState().logEvent("NEXT_PRESSED");
nextStep();
}}>Next</Button>
</div>
</div>
}
+212
View File
@@ -0,0 +1,212 @@
import basicsVideo from '/videos/Basics.mp4';
import entitiesVideo from '/videos/Entities.mp4';
import locationsVideo from '/videos/Locations.mp4';
import reorderVideo from '/videos/Reorder.mp4';
import { Entity, Location, ModelState } from '../model/Model';
import { dataTextB, textB } from './data/TextB';
import { dataTextC, textC } from './data/TextC';
export type StudyStepType = "TASK" | "MESSAGE" | "VIDEO" | "FREEFORM_LAUNCHER" | "TEST_TOOL";
export type StudyTask = "EDIT_ENTITIES" | "MOVE_ENTITIES" | "REORDER_EVENTS" | "NOT_A_TASK" | "FREE_FORM" |
"READING_BEFORE_PLANNING" |
"PLANNING_CHARACTERS" | "PLANNING_SPACE" | "PLANNING_TEMPORALITY" | "PLANNING_FOCALIZATION";
export type StudyCondition = "VISUALWRITING" | "BASELINE" | "NOT_A_CONDITION";
export interface StudyStep {
type: StudyStepType;
instructions?: string[];
message?: string;
startingState?: Partial<ModelState>;
task: StudyTask;
condition: StudyCondition;
saveData?: boolean;
hardcodedData?: {actions: any[], entities: Entity[], locations: Location[]};
}
export class WritingStudyTaskGenerator {
static generateSteps(participantId: number): StudyStep[] {
let steps : StudyStep[] = [];
// First, add the opening message with the link to complete the demographic survey
steps.push({
condition: "NOT_A_CONDITION",
task: "NOT_A_TASK",
type: "MESSAGE",
message: `Thank you for participating in this study. Please, first complete this survey questionnaire: <a>Demographic survey</a>. After completing the survey, return to this page to continue the study.`,
});
steps.push({
type: "VIDEO",
task: "NOT_A_TASK",
condition: "VISUALWRITING",
saveData: false,
message: basicsVideo,
});
steps.push({
condition: "NOT_A_CONDITION",
task: "NOT_A_TASK",
type: "MESSAGE",
message: `Now you will watch a second video that will introduce how to edit the story.`,
});
const latinSquare = [
[['E1','M1','R1']],
[['E1','R1','M1']],
[['R1','E1','M1']],
[['R1','M1','E1']],
[['M1','R1','E1']],
[['M1','E1','R1']],
];
const order = latinSquare[(participantId-1) % latinSquare.length];
for (let conditionIdx = 0; conditionIdx < order.length; ++conditionIdx) {
const condition = "VISUALWRITING";//(participantId % 2) !== conditionIdx ? "VISUALWRITING" : "BASELINE";
const tasks = order[conditionIdx];
tasks.forEach((taskId, idx) => {
const task = taskDictionary[taskId];
const videoName : {[name: string]: string} = {
"E": entitiesVideo,
"M": locationsVideo,
"R": reorderVideo,
}
steps.push({
type: "VIDEO",
task: "NOT_A_TASK",
condition: condition,
saveData: idx !== 0,
message: videoName[taskId[0]],
});
const taskName : {[name: string]: string} = {
"E": 'Entities-Event',
"M": 'Entities-Location',
"R": 'Entities-Order',
}
steps.push({
...task,
type: "TASK",
condition: condition,
})
const url = ``
steps.push({
type: "MESSAGE",
task: "NOT_A_TASK",
condition: condition,
saveData: true,
message: `Feel free to take a break. Please answer this questionnaire: <a href='${url}' target="_blank">Questionnaire</a>.` ,
});
});
}
/*steps.push({
type: "VIDEO",
task: "NOT_A_TASK",
condition: "VISUALWRITING",
message: freeformVideo,
});*/
/*steps.push({
type: "FREEFORM_LAUNCHER",
task: "NOT_A_TASK",
condition: "VISUALWRITING",
});*/
steps.push({
type: "TASK",
task: "FREE_FORM",
condition: "VISUALWRITING",
instructions: ["Using the beginning of the story, explore what could happen next..."],
startingState: { textState: [{ children: [{ text: textC }]}]},
hardcodedData: dataTextC
});
const url = "";
//const url = `https://docs.google.com/forms/d/e/1FAIpQLSdoVCmxaLUTmEoGgecHhwWhuMeXWBLHzsh3CmgqNolwpW64Lg/viewform?usp=pp_url&entry.1411851810=${participantId}`
steps.push({
type: "MESSAGE",
task: "NOT_A_TASK",
condition: "VISUALWRITING",
saveData: true,
message: `Please answer this questionnaire: <a href='${url}' target="_blank">Questionnaire</a>.` ,
});
return steps;
}
}
const taskDictionary: { [name: string]: StudyStep} = {
/**
* WRITE AN EMAIL USING A TEMPLATE
* VARIATION 1
*/
"E1": {
task: "EDIT_ENTITIES",
instructions: [
"What if the hay bales were not there?",
"What if Jack went sledding with a friend?"
],
startingState: {
textState: [
{
children: [
{text: textB}
]
},
]
},
hardcodedData: dataTextB
} as StudyStep,
"M1": {
task: "MOVE_ENTITIES",
instructions: [
"What if Jack had forgotten his hat at home?",
"What if Jack flies into a place he's never been to before?"
],
startingState: { textState: [{ children: [{ text: textB }]}]},
hardcodedData: dataTextB
} as StudyStep,
"R1": {
task: "REORDER_EVENTS",
instructions: [
"What if we move the last scene to the start of the story?",
"What if Jack loses his hat when he flies into the sky?"
],
startingState: { textState: [{ children: [{ text: textB }]}]},
hardcodedData: dataTextB
} as StudyStep,
} as any;
+381
View File
@@ -0,0 +1,381 @@
import { Entity, Location } from "../../model/Model";
export const textA = `Julia sat at a small table outside the café stirring her coffee absentmindedly. The young waitress clumsily cleared the table next to her, making her remember when she was the young ambitious waitress who just moved to the big city. She wondered where this young waitress came from and what the gossip amongst the staff of this café was.
Sams alarm blared at full volume through her phone speakers. As she reached her arm out to press snooze for the third time, she suddenly remembered. The pounding of her heart grew louder and louder as she peeked at her phone 10:05.
“Oh no,” she muttered as she scrambled out of bed. She had set 5 alarms and yet, she was still in bed.
"omw!" She texted Julia, feeling a pang of guilt.
CLANG
The waiter apologized as she bumped into Julia.
“Um, is there anything I can get for you?”
“Its ok. Im good, thanks,” Julia said before giving a polite smile. Her phone buzzed with a message from Sam which read exactly what she expected.
Sam ran to the bathroom, cursing herself for never being able to wake up to her alarms. Her hair was a mess but there was no time to do anything about it. She grabbed her bag from her room then sprinted through the streets. Guilt gnawing at her conscience, she hoped she wouldnt be too too late.
“Would you like another cup of coffee?” The waiter asked, pointing to the cup. The table next to Julia had cleared once again.
Julia thought for a second before nodding. “Sure.”
She pulled out her phone, looking at Sams text from 30 minutes ago.
At 10:55, Julia sighed and took a sip of her second cup of lukewarm coffee. She could picture Sam running out her door, hair all over, panicking while checking the time. Julia tried not to be annoyed but this was not the first time. Or the second. She leaned back in her chair, debating whether to order breakfast alone or wait a little longer. Across the street, Sam sees Julia sitting at the front of the café.`
export const dataTextA : {locations: Location[], entities: Entity[], actions: any[]} = {
locations: [
{
"name": "Café",
"emoji": "☕"
},
{
"name": "Sam's Bedroom",
"emoji": "🛏️"
},
{
"name": "Sam's Bathroom",
"emoji": "🚽"
},
{
"name": "Street",
"emoji": "🏃‍♀️"
}
],
entities: [
{
"name": "Julia",
"emoji": "👩",
"properties": [
{
"name": "patient",
"value": 7
},
{
"name": "nostalgic",
"value": 6
},
{
"name": "polite",
"value": 8
}
]
},
{
"name": "Sam",
"emoji": "🙋‍♀️",
"properties": [
{
"name": "forgetful",
"value": 8
},
{
"name": "rushed",
"value": 9
},
{
"name": "guilty",
"value": 7
}
]
},
{
"name": "Waitress",
"emoji": "👩‍🍳",
"properties": [
{
"name": "clumsy",
"value": 6
},
{
"name": "young",
"value": 5
}
]
},
{
"name": "Bag",
"emoji": "🎒",
"properties": [
{
"name": "heavy",
"value": 5
},
{
"name": "forgotten",
"value": 6
}
]
},
{
"name": "Alarm",
"emoji": "⏰",
"properties": [
{
"name": "loud",
"value": 9
},
{
"name": "ignored",
"value": 8
}
]
}
],
actions: [
{
"name": "sat",
"source": "Julia",
"target": "Julia",
"location": "Café",
"passage": "Julia sat at a small table outside the café stirring her coffee absentmindedly."
},
{
"name": "stirring coffee",
"source": "Julia",
"target": "Julia",
"location": "Café",
"passage": "Julia sat at a small table outside the café stirring her coffee absentmindedly."
},
{
"name": "cleared table",
"source": "Waitress",
"target": "Waitress",
"location": "Café",
"passage": "The young waitress clumsily cleared the table next to her, making her remember when she was the young ambitious waitress who just moved to the big city."
},
{
"name": "blared",
"source": "Alarm",
"target": "Sam",
"location": "unknown",
"passage": "Sams alarm blared at full volume through her phone speakers."
},
{
"name": "reached out",
"source": "Sam",
"target": "Alarm",
"location": "unknown",
"passage": "As she reached her arm out to press snooze for the third time, she suddenly remembered."
},
{
"name": "press snooze",
"source": "Sam",
"target": "Alarm",
"location": "unknown",
"passage": "As she reached her arm out to press snooze for the third time, she suddenly remembered."
},
{
"name": "peeked at phone",
"source": "Sam",
"target": "Phone",
"location": "unknown",
"passage": "The pounding of her heart grew louder and louder as she peeked at her phone 10:05."
},
{
"name": "muttered",
"source": "Sam",
"target": "Sam",
"location": "unknown",
"passage": "“Oh no,” she muttered as she scrambled out of bed."
},
{
"name": "scrambled out",
"source": "Sam",
"target": "Sam",
"location": "unknown",
"passage": "“Oh no,” she muttered as she scrambled out of bed."
},
{
"name": "set alarms",
"source": "Sam",
"target": "Alarm",
"location": "unknown",
"passage": "She had set 5 alarms and yet, she was still in bed.\nomw!"
},
{
"name": "be in bed",
"source": "Sam",
"target": "Sam",
"location": "unknown",
"passage": "She had set 5 alarms and yet, she was still in bed.\nomw!"
},
{
"name": "texted",
"source": "Sam",
"target": "Julia",
"location": "unknown",
"passage": "She texted Julia, feeling a pang of guilt."
},
{
"name": "bumped into",
"source": "Waitress",
"target": "Julia",
"location": "Café",
"passage": "CLANG\nThe waiter apologized as she bumped into Julia."
},
{
"name": "apologized",
"source": "Waitress",
"target": "Julia",
"location": "Café",
"passage": "CLANG\nThe waiter apologized as she bumped into Julia."
},
{
"name": "said",
"source": "Julia",
"target": "Waitress",
"location": "Café",
"passage": "Im good, thanks,” Julia said before giving a polite smile."
},
{
"name": "smile",
"source": "Julia",
"target": "Waitress",
"location": "Café",
"passage": "Im good, thanks,” Julia said before giving a polite smile."
},
{
"name": "buzzed",
"source": "Phone",
"target": "Sam",
"location": "unknown",
"passage": "Her phone buzzed with a message from Sam which read exactly what she expected."
},
{
"name": "ran",
"source": "Sam",
"target": "Sam",
"location": "Sam's Bathroom",
"passage": "Sam ran to the bathroom, cursing herself for never being able to wake up to her alarms."
},
{
"name": "cursing",
"source": "Sam",
"target": "Sam",
"location": "Sam's Bathroom",
"passage": "Sam ran to the bathroom, cursing herself for never being able to wake up to her alarms."
},
{
"name": "grabbed",
"source": "Sam",
"target": "Bag",
"location": "Sam's Bedroom",
"passage": "She grabbed her bag from her room then sprinted through the streets."
},
{
"name": "sprinted through streets",
"source": "Sam",
"target": "Sam",
"location": "Street",
"passage": "She grabbed her bag from her room then sprinted through the streets."
},
{
"name": "ask for coffee",
"source": "Waitress",
"target": "Julia",
"location": "Café",
"passage": "” The waiter asked, pointing to the cup."
},
{
"name": "point to cup",
"source": "Waitress",
"target": "Julia",
"location": "Café",
"passage": "” The waiter asked, pointing to the cup."
},
{
"name": "clear table",
"source": "Waitress",
"target": "Waitress",
"location": "Café",
"passage": "The table next to Julia had cleared once again."
},
{
"name": "think",
"source": "Julia",
"target": "Julia",
"location": "Café",
"passage": "Julia thought for a second before nodding. “Sure."
},
{
"name": "nod",
"source": "Julia",
"target": "Waitress",
"location": "Café",
"passage": "Julia thought for a second before nodding. “Sure."
},
{
"name": "pulled out",
"source": "Julia",
"target": "Phone",
"location": "Café",
"passage": "”\nShe pulled out her phone, looking at Sams text from 30 minutes ago."
},
{
"name": "looking at",
"source": "Julia",
"target": "Text",
"location": "Café",
"passage": "”\nShe pulled out her phone, looking at Sams text from 30 minutes ago."
},
{
"name": "sighed",
"source": "Julia",
"target": "Julia",
"location": "Café",
"passage": "At 10:55, Julia sighed and took a sip of her second cup of lukewarm coffee."
},
{
"name": "took a sip",
"source": "Julia",
"target": "Julia",
"location": "Café",
"passage": "At 10:55, Julia sighed and took a sip of her second cup of lukewarm coffee."
},
{
"name": "picture running",
"source": "Julia",
"target": "Sam",
"location": "Café",
"passage": "She could picture Sam running out her door, hair all over, panicking while checking the time."
},
{
"name": "picture panicking",
"source": "Julia",
"target": "Sam",
"location": "Café",
"passage": "She could picture Sam running out her door, hair all over, panicking while checking the time."
},
{
"name": "picture checking",
"source": "Julia",
"target": "Sam",
"location": "Café",
"passage": "She could picture Sam running out her door, hair all over, panicking while checking the time."
},
{
"name": "tried not to be annoyed",
"source": "Julia",
"target": "Julia",
"location": "Café",
"passage": "Julia tried not to be annoyed but this was not the first time. Or the second."
},
{
"name": "leaned back",
"source": "Julia",
"target": "Julia",
"location": "Café",
"passage": "She leaned back in her chair, debating whether to order breakfast alone or wait a little longer."
},
{
"name": "debating",
"source": "Julia",
"target": "Julia",
"location": "Café",
"passage": "She leaned back in her chair, debating whether to order breakfast alone or wait a little longer."
},
{
"name": "sees Julia",
"source": "Sam",
"target": "Julia",
"location": "Café",
"passage": "Across the street, Sam sees Julia sitting at the front of the café."
}
]
};
+187
View File
@@ -0,0 +1,187 @@
import { Entity, Location } from "../../model/Model";
export const textAlice = `Alice was beginning to get very tired of sitting by her sister on the bank, and of having nothing to do: once or twice she had peeped into the book her sister was reading, but it had no pictures or conversations in it, “and what is the use of a book,” thought Alice “without pictures or conversations?”
So she was considering in her own mind (as well as she could, for the hot day made her feel very sleepy and stupid), whether the pleasure of making a daisy-chain would be worth the trouble of getting up and picking the daisies, when suddenly a White Rabbit with pink eyes ran close by her.
There was nothing so _very_ remarkable in that; nor did Alice think it so _very_ much out of the way to hear the Rabbit say to itself, “Oh dear! Oh dear! I shall be late!” (when she thought it over afterwards, it occurred to her that she ought to have wondered at this, but at the time it all seemed quite natural); but when the Rabbit actually _took a watch out of its waistcoat-pocket_, and looked at it, and then hurried on, Alice started to her feet, for it flashed across her mind that she had never before seen a rabbit with either a waistcoat-pocket, or a watch to take out of it, and burning with curiosity, she ran across the field after it, and fortunately was just in time to see it pop down a large rabbit-hole under the hedge.
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n`;
export const dataTextAlice : {locations: Location[], entities: Entity[], actions: any[]} = {
entities: [
{
"name": "Alice",
"emoji": "👧",
"properties": [
{
"name": "curious",
"value": 8
},
{
"name": "sleepy",
"value": 6
},
{
"name": "bored",
"value": 7
}
]
},
{
"name": "Sister",
"emoji": "👩",
"properties": [
{
"name": "reading",
"value": 7
}
]
},
{
"name": "Book",
"emoji": "📖",
"properties": [
{
"name": "pictureless",
"value": 10
},
{
"name": "conversationless",
"value": 10
}
]
},
{
"name": "Daisy-chain",
"emoji": "🌼",
"properties": [
{
"name": "pleasurable",
"value": 5
}
]
},
{
"name": "White Rabbit",
"emoji": "🐇",
"properties": [
{
"name": "anxious",
"value": 9
},
{
"name": "remarkable",
"value": 7
}
]
},
{
"name": "Watch",
"emoji": "⌚",
"properties": [
{
"name": "unusual",
"value": 8
}
]
},
{
"name": "Rabbit-hole",
"emoji": "🕳️",
"properties": [
{
"name": "large",
"value": 7
}
]
}
],
locations: [
{
"name": "bank",
"emoji": "🏞️"
},
{
"name": "field",
"emoji": "🌾"
},
{
"name": "hedge",
"emoji": "🌳"
}
],
actions: [
{
"name": "sit by",
"source": "Alice",
"target": "Sister",
"location": "bank",
"passage": "Alice was beginning to get very tired of sitting by her sister on the bank."
},
{
"name": "peep into",
"source": "Alice",
"target": "Book",
"location": "bank",
"passage": "once or twice she had peeped into the book her sister was reading."
},
{
"name": "consider making",
"source": "Alice",
"target": "Daisy-chain",
"location": "bank",
"passage": "she was considering in her own mind... whether the pleasure of making a daisy-chain would be worth the trouble of getting up and picking the daisies."
},
{
"name": "run by",
"source": "White Rabbit",
"target": "Alice",
"location": "bank",
"passage": "when suddenly a White Rabbit with pink eyes ran close by her."
},
{
"name": "say to",
"source": "White Rabbit",
"target": "itself",
"location": "unknown",
"passage": "nor did Alice think it so very much out of the way to hear the Rabbit say to itself, 'Oh dear! Oh dear! I shall be late!'."
},
{
"name": "take out",
"source": "White Rabbit",
"target": "Watch",
"location": "unknown",
"passage": "but when the Rabbit actually took a watch out of its waistcoat-pocket."
},
{
"name": "look at",
"source": "White Rabbit",
"target": "Watch",
"location": "unknown",
"passage": "and looked at it."
},
{
"name": "hurry on",
"source": "White Rabbit",
"target": "unknown",
"location": "unknown",
"passage": "and then hurried on."
},
{
"name": "run after",
"source": "Alice",
"target": "White Rabbit",
"location": "field",
"passage": "burning with curiosity, she ran across the field after it."
},
{
"name": "pop down",
"source": "White Rabbit",
"target": "Rabbit-hole",
"location": "hedge",
"passage": "and fortunately was just in time to see it pop down a large rabbit-hole under the hedge."
}
]
};
+352
View File
@@ -0,0 +1,352 @@
import { Entity, Location } from "../../model/Model";
export const textB = `One snowy winter morning, Jack jumped up and looked out his window to see the world covered in thick sparkling snow. Perfect sledding snow!
He threw on his snow pants and jacket, then dashed outside before his mom could even finish saying, "dont forget your hat!"
But Jack was already at the hill. He took a deep breath in and began his climb, dragging his sled behind him. He climbed higher and higher until he finally reached the top. As he looked down, the hill felt so large to the 8-year-old Jack that it seemed like a mountain.
Without waiting another second, he positioned his sled strategically and loaded himself on. WOOSH!
He zoomed down the hill so fast that his hat flew right off his head.
He zoomed past all the children climbing the hill.
He zoomed past the parents waiting at the bottom of the hill. But he didn't slow down.
He zoomed faster and faster, and crashed into the hay bales.
He flew off his sled and into the air. He soared through the sky, flying past a flock of noisy geese.`
export const dataTextB : {locations: Location[], entities: Entity[], actions: any[]} = {
locations: [
{
"name": "Jack's House",
"emoji": "🏠"
},
{
"name": "The Hill",
"emoji": "⛰️"
},
{
"name": "The Bottom of the Hill",
"emoji": "🏞️"
},
{
"name": "The Hay Bales",
"emoji": "🌾"
},
{
"name": "The Sky",
"emoji": "☁️"
}
],
entities: [
{
"name": "Jack",
"emoji": "👦",
"properties": [
{
"name": "excited",
"value": 9
},
{
"name": "adventurous",
"value": 8
},
{
"name": "energetic",
"value": 10
}
]
},
{
"name": "snow",
"emoji": "❄️",
"properties": [
{
"name": "sparkling",
"value": 8
},
{
"name": "thick",
"value": 7
},
{
"name": "cold",
"value": 9
}
]
},
{
"name": "sled",
"emoji": "🛷",
"properties": [
{
"name": "fast",
"value": 9
},
{
"name": "smooth",
"value": 8
},
{
"name": "lightweight",
"value": 7
}
]
},
{
"name": "hill",
"emoji": "⛰️",
"properties": [
{
"name": "steep",
"value": 8
},
{
"name": "large",
"value": 7
},
{
"name": "snowy",
"value": 9
}
]
},
{
"name": "hat",
"emoji": "🎩",
"properties": [
{
"name": "warm",
"value": 7
},
{
"name": "colorful",
"value": 6
},
{
"name": "light",
"value": 5
}
]
},
{
"name": "hay bales",
"emoji": "🌾",
"properties": [
{
"name": "soft",
"value": 6
},
{
"name": "protective",
"value": 8
},
{
"name": "stacked",
"value": 7
}
]
},
{
"name": "geese",
"emoji": "🦢",
"properties": [
{
"name": "noisy",
"value": 8
},
{
"name": "flying",
"value": 9
},
{
"name": "flock",
"value": 7
}
]
},
{
name: "parents",
emoji: "👨‍👩‍👦",
properties: [
{
name: "waiting",
value: 7
},
{
name: "watching",
value: 8
},
{
name: "smiling",
value: 9
}
]
},
{
name: "children",
emoji: "🧒",
properties: [
{
name: "climbing",
value: 8
},
]
}
],
actions: [
{
"name": "jumped up",
"source": "Jack",
"target": "Jack",
"location": "Jack's House",
"passage": "One snowy winter morning, Jack jumped up and looked out his window to see the world covered in thick sparkling snow."
},
{
"name": "looked out",
"source": "Jack",
"target": "Jack",
"location": "Jack's House",
"passage": "One snowy winter morning, Jack jumped up and looked out his window to see the world covered in thick sparkling snow."
},
{
"name": "threw on",
"source": "Jack",
"target": "Jack",
"location": "Jack's House",
"passage": "He threw on his snow pants and jacket, then dashed outside before his mom could even finish saying, \"dont forget your hat!"
},
{
"name": "dashed outside",
"source": "Jack",
"target": "Jack",
"location": "Jack's House",
"passage": "He threw on his snow pants and jacket, then dashed outside before his mom could even finish saying, \"dont forget your hat!"
},
{
"name": "breathe in",
"source": "Jack",
"target": "Jack",
"location": "The Hill",
"passage": "He took a deep breath in and began his climb, dragging his sled behind him."
},
{
"name": "climb",
"source": "Jack",
"target": "hill",
"location": "The Hill",
"passage": "He took a deep breath in and began his climb, dragging his sled behind him."
},
{
"name": "drag",
"source": "Jack",
"target": "sled",
"location": "The Hill",
"passage": "He took a deep breath in and began his climb, dragging his sled behind him."
},
{
"name": "climbed higher",
"source": "Jack",
"target": "hill",
"location": "The Hill",
"passage": "He climbed higher and higher until he finally reached the top."
},
{
"name": "reached top",
"source": "Jack",
"target": "hill",
"location": "The Hill",
"passage": "He climbed higher and higher until he finally reached the top."
},
{
"name": "looked down",
"source": "Jack",
"target": "hill",
"location": "The Hill",
"passage": "As he looked down, the hill felt so large to the 8-year-old Jack that it seemed like a mountain."
},
{
"name": "positioned sled",
"source": "Jack",
"target": "sled",
"location": "The Hill",
"passage": "Without waiting another second, he positioned his sled strategically and loaded himself on. WOOSH!"
},
{
"name": "loaded himself",
"source": "Jack",
"target": "Jack",
"location": "The Hill",
"passage": "Without waiting another second, he positioned his sled strategically and loaded himself on. WOOSH!"
},
{
"name": "zoomed down",
"source": "Jack",
"target": "hill",
"location": "The Hill",
"passage": "He zoomed down the hill so fast that his hat flew right off his head."
},
{
"name": "flew off",
"source": "hat",
"target": "hat",
"location": "The Hill",
"passage": "He zoomed down the hill so fast that his hat flew right off his head."
},
{
"name": "zoomed past",
"source": "Jack",
"target": "children",
"location": "The Hill",
"passage": "He zoomed past all the children climbing the hill."
},
{
"name": "zoomed past",
"source": "Jack",
"target": "parents",
"location": "The Bottom of the Hill",
"passage": "He zoomed past the parents waiting at the bottom of the hill."
},
{
"name": "zoomed",
"source": "Jack",
"target": "hill",
"location": "The Hill",
"passage": "He zoomed faster and faster, and crashed into the hay bales."
},
{
"name": "crashed",
"source": "Jack",
"target": "hay bales",
"location": "The Hay Bales",
"passage": "He zoomed faster and faster, and crashed into the hay bales."
},
{
"name": "flew off",
"source": "Jack",
"target": "Jack",
"location": "The Hay Bales",
"passage": "He flew off his sled and into the air."
},
{
"name": "into the air",
"source": "Jack",
"target": "Jack",
"location": "The Sky",
"passage": "He flew off his sled and into the air."
},
{
"name": "soared",
"source": "Jack",
"target": "Jack",
"location": "The Sky",
"passage": "He soared through the sky, flying past a flock of noisy geese."
},
{
"name": "flying past",
"source": "Jack",
"target": "geese",
"location": "The Sky",
"passage": "He soared through the sky, flying past a flock of noisy geese."
}
]
};
+79
View File
@@ -0,0 +1,79 @@
import { Entity, Location } from "../../model/Model";
export const textC = `Every winter, geese fly long distances and trade the freezing temperatures of the north for the warmth of the south. Robert was one of such geese and, as a goose, Robert doesn't ask many questions... But what is a human doing flying in the sky beside him?`
export const dataTextC : {locations: Location[], entities: Entity[], actions: any[]} = {
locations: [
{
"name": "Open Skies",
"emoji": "☁️"
},
],
entities: [
{
"name": "Robert",
"emoji": "🦢",
"properties": [
{
"name": "curious",
"value": 7
},
{
"name": "adventurous",
"value": 6
},
{
"name": "determined",
"value": 8
}
]
},
{
"name": "human",
"emoji": "🧑",
"properties": [
{
"name": "mysterious",
"value": 9
},
{
"name": "unexpected",
"value": 8
},
{
"name": "intriguing",
"value": 7
}
]
},
{
name: 'geese',
emoji: '🦢',
properties: [
]
}
],
actions: [
{
"name": "fly long distances",
"source": "geese",
"target": "geese",
"location": "unknown",
"passage": "Every winter, geese fly long distances and trade the freezing temperatures of the north for the warmth of the south."
},
{
"name": "trade freezing temperatures",
"source": "geese",
"target": "geese",
"location": "unknown",
"passage": "Every winter, geese fly long distances and trade the freezing temperatures of the north for the warmth of the south."
},
{
"name": "flying",
"source": "human",
"target": "Robert",
"location": "Open Skies",
"passage": "But what is a human doing flying in the sky beside him?"
}
]
};
+206
View File
@@ -0,0 +1,206 @@
import { Entity, Location } from "../../model/Model";
export const textD = `Anna sat on the beach, watching the waves crash against the shore. The wind blew her hair around, but she didnt mind. She loved the sound of the ocean. It helped her forget her worries, at least for a little while. She had been thinking about her brother, David, who lived far away. They hadnt spoken in weeks, and she missed him.
David was in the city, sitting at his desk, staring at his computer. He was tired from a long day of work. His job was stressful, and he often felt lonely in the big, noisy city. He wanted to call Anna, but he was afraid she might be too busy. He knew she was going through a tough time, and he didnt want to add to her troubles.
Meanwhile, their friend Emma was in the mountains, hiking up a trail. She loved the peacefulness of nature. The trees were tall, and the air was fresh. As she reached the top of the hill, she thought about Anna and David. They used to do everything together, but now they were all in different places. She hoped they could reunite soon, even if just for a little while.`
export const dataTextD : {locations: Location[], entities: Entity[], actions: any[]} = {
locations: [
{
"name": "Beach",
"emoji": "🏖️"
},
{
"name": "City",
"emoji": "🏙️"
},
{
"name": "Mountains",
"emoji": "🏞️"
}
],
entities: [
{
"name": "Anna",
"emoji": "👩",
"properties": [
{
"name": "thoughtful",
"value": 8
},
{
"name": "calm",
"value": 7
},
{
"name": "nostalgic",
"value": 6
}
]
},
{
"name": "David",
"emoji": "👨‍💼",
"properties": [
{
"name": "tired",
"value": 9
},
{
"name": "lonely",
"value": 7
},
{
"name": "considerate",
"value": 6
}
]
},
{
"name": "Emma",
"emoji": "🚶‍♀️",
"properties": [
{
"name": "adventurous",
"value": 8
},
{
"name": "peaceful",
"value": 7
},
{
"name": "hopeful",
"value": 6
}
]
},
{
"name": "Computer",
"emoji": "💻",
"properties": [
{
"name": "distracting",
"value": 8
},
{
"name": "stressful",
"value": 7
},
{
"name": "isolating",
"value": 6
}
]
},
{
"name": "Waves",
"emoji": "🌊",
"properties": [
{
"name": "calming",
"value": 8
},
{
"name": "soothing",
"value": 7
},
{
"name": "refreshing",
"value": 6
}
]
},
{
"name": "Wind",
"emoji": "💨",
"properties": [
{
"name": "refreshing",
"value": 8
},
{
"name": "playful",
"value": 7
},
{
"name": "invigorating",
"value": 6
}
]
}
],
actions: [
{
"name": "sat",
"source": "Anna",
"target": "Anna",
"location": "Beach",
"passage": "Anna sat on the beach, watching the waves crash against the shore."
},
{
"name": "watching",
"source": "Anna",
"target": "Waves",
"location": "Beach",
"passage": "Anna sat on the beach, watching the waves crash against the shore."
},
{
"name": "blew hair",
"source": "Wind",
"target": "Anna",
"location": "Beach",
"passage": "The wind blew her hair around, but she didnt mind."
},
{
"name": "sitting",
"source": "David",
"target": "David",
"location": "City",
"passage": "David was in the city, sitting at his desk, staring at his computer."
},
{
"name": "staring",
"source": "David",
"target": "Computer",
"location": "City",
"passage": "David was in the city, sitting at his desk, staring at his computer."
},
{
"name": "wanted to call",
"source": "David",
"target": "Anna",
"location": "City",
"passage": "He wanted to call Anna, but he was afraid she might be too busy."
},
{
"name": "hiking up",
"source": "Emma",
"target": "Emma",
"location": "Mountains",
"passage": "Meanwhile, their friend Emma was in the mountains, hiking up a trail."
},
{
"name": "reach top",
"source": "Emma",
"target": "Emma",
"location": "Mountains",
"passage": "As she reached the top of the hill, she thought about Anna and David."
},
{
"name": "think about",
"source": "Emma",
"target": "Anna",
"location": "Mountains",
"passage": "As she reached the top of the hill, she thought about Anna and David."
},
{
"name": "think about",
"source": "Emma",
"target": "David",
"location": "Mountains",
"passage": "As she reached the top of the hill, she thought about Anna and David."
}
]
};
+171
View File
@@ -0,0 +1,171 @@
import { Entity, Location } from "../../model/Model";
export const textE = `Alice was beginning to get very tired of sitting by her sister on the bank, and of having nothing to do: once or twice she had peeped into the book her sister was reading, but it had no pictures or conversations in it, “and what is the use of a book,” thought Alice “without pictures or conversations?”
So she was considering in her own mind (as well as she could, for the hot day made her feel very sleepy and stupid), whether the pleasure of making a daisy-chain would be worth the trouble of getting up and picking the daisies, when suddenly a White Rabbit with pink eyes ran close by her.
There was nothing so _very_ remarkable in that; nor did Alice think it so _very_ much out of the way to hear the Rabbit say to itself, “Oh dear! Oh dear! I shall be late!” (when she thought it over afterwards, it occurred to her that she ought to have wondered at this, but at the time it all seemed quite natural); but when the Rabbit actually _took a watch out of its waistcoat-pocket_, and looked at it, and then hurried on, Alice started to her feet, for it flashed across her mind that she had never before seen a rabbit with either a waistcoat-pocket, or a watch to take out of it, and burning with curiosity, she ran across the field after it, and fortunately was just in time to see it pop down a large rabbit-hole under the hedge.`
export const dataTextE : {locations: Location[], entities: Entity[], actions: any[]} = {
entities: [
{
"name": "Alice",
"emoji": "👧",
"properties": [
{
"name": "curious",
"value": 9
},
{
"name": "sleepy",
"value": 6
},
{
"name": "imaginative",
"value": 8
}
]
},
{
"name": "Sister",
"emoji": "👩",
"properties": [
{
"name": "focused",
"value": 7
},
{
"name": "quiet",
"value": 5
}
]
},
{
"name": "White Rabbit",
"emoji": "🐇",
"properties": [
{
"name": "anxious",
"value": 8
},
{
"name": "punctual",
"value": 7
},
{
"name": "unusual",
"value": 9
}
]
},
{
"name": "Book",
"emoji": "📖",
"properties": [
{
"name": "boring",
"value": 8
},
{
"name": "plain",
"value": 7
}
]
}
],
locations: [
{
"name": "The Bank",
"emoji": "🏞️"
},
{
"name": "The Field",
"emoji": "🌾"
},
{
"name": "The Rabbit Hole",
"emoji": "🕳️"
}
],
actions: [
{
"name": "sitting by",
"source": "Alice",
"target": "Sister",
"location": "The Bank",
"passage": "Alice was beginning to get very tired of sitting by her sister on the bank, and of having nothing to do: once or twice she had peeped into the book her sister was reading, but it had no pictures or conversations in it, “and what is the use of a book,” thought Alice “without pictures or conversations?"
},
{
"name": "peeped into",
"source": "Alice",
"target": "Book",
"location": "The Bank",
"passage": "Alice was beginning to get very tired of sitting by her sister on the bank, and of having nothing to do: once or twice she had peeped into the book her sister was reading, but it had no pictures or conversations in it, “and what is the use of a book,” thought Alice “without pictures or conversations?"
},
{
"name": "considering",
"source": "Alice",
"target": "Alice",
"location": "The Bank",
"passage": "”\n\nSo she was considering in her own mind (as well as she could, for the hot day made her feel very sleepy and stupid), whether the pleasure of making a daisy-chain would be worth the trouble of getting up and picking the daisies, when suddenly a White Rabbit with pink eyes ran close by her."
},
{
"name": "ran close",
"source": "White Rabbit",
"target": "Alice",
"location": "The Bank",
"passage": "”\n\nSo she was considering in her own mind (as well as she could, for the hot day made her feel very sleepy and stupid), whether the pleasure of making a daisy-chain would be worth the trouble of getting up and picking the daisies, when suddenly a White Rabbit with pink eyes ran close by her."
},
{
"name": "say to itself",
"source": "White Rabbit",
"target": "White Rabbit",
"location": "unknown",
"passage": "There was nothing so _very_ remarkable in that; nor did Alice think it so _very_ much out of the way to hear the Rabbit say to itself, “Oh dear! Oh dear! I shall be late!"
},
{
"name": "took watch",
"source": "White Rabbit",
"target": "White Rabbit",
"location": "unknown",
"passage": "” (when she thought it over afterwards, it occurred to her that she ought to have wondered at this, but at the time it all seemed quite natural); but when the Rabbit actually _took a watch out of its waistcoat-pocket_, and looked at it, and then hurried on, Alice started to her feet, for it flashed across her mind that she had never before seen a rabbit with either a waistcoat-pocket, or a watch to take out of it, and burning with curiosity, she ran across the field after it, and fortunately was just in time to see it pop down a large rabbit-hole under the hedge."
},
{
"name": "looked at watch",
"source": "White Rabbit",
"target": "White Rabbit",
"location": "unknown",
"passage": "” (when she thought it over afterwards, it occurred to her that she ought to have wondered at this, but at the time it all seemed quite natural); but when the Rabbit actually _took a watch out of its waistcoat-pocket_, and looked at it, and then hurried on, Alice started to her feet, for it flashed across her mind that she had never before seen a rabbit with either a waistcoat-pocket, or a watch to take out of it, and burning with curiosity, she ran across the field after it, and fortunately was just in time to see it pop down a large rabbit-hole under the hedge."
},
{
"name": "hurried on",
"source": "White Rabbit",
"target": "White Rabbit",
"location": "unknown",
"passage": "” (when she thought it over afterwards, it occurred to her that she ought to have wondered at this, but at the time it all seemed quite natural); but when the Rabbit actually _took a watch out of its waistcoat-pocket_, and looked at it, and then hurried on, Alice started to her feet, for it flashed across her mind that she had never before seen a rabbit with either a waistcoat-pocket, or a watch to take out of it, and burning with curiosity, she ran across the field after it, and fortunately was just in time to see it pop down a large rabbit-hole under the hedge."
},
{
"name": "started to feet",
"source": "Alice",
"target": "Alice",
"location": "The Field",
"passage": "” (when she thought it over afterwards, it occurred to her that she ought to have wondered at this, but at the time it all seemed quite natural); but when the Rabbit actually _took a watch out of its waistcoat-pocket_, and looked at it, and then hurried on, Alice started to her feet, for it flashed across her mind that she had never before seen a rabbit with either a waistcoat-pocket, or a watch to take out of it, and burning with curiosity, she ran across the field after it, and fortunately was just in time to see it pop down a large rabbit-hole under the hedge."
},
{
"name": "ran across field",
"source": "Alice",
"target": "White Rabbit",
"location": "The Field",
"passage": "” (when she thought it over afterwards, it occurred to her that she ought to have wondered at this, but at the time it all seemed quite natural); but when the Rabbit actually _took a watch out of its waistcoat-pocket_, and looked at it, and then hurried on, Alice started to her feet, for it flashed across her mind that she had never before seen a rabbit with either a waistcoat-pocket, or a watch to take out of it, and burning with curiosity, she ran across the field after it, and fortunately was just in time to see it pop down a large rabbit-hole under the hedge."
},
{
"name": "popped down",
"source": "White Rabbit",
"target": "White Rabbit",
"location": "The Rabbit Hole",
"passage": "” (when she thought it over afterwards, it occurred to her that she ought to have wondered at this, but at the time it all seemed quite natural); but when the Rabbit actually _took a watch out of its waistcoat-pocket_, and looked at it, and then hurried on, Alice started to her feet, for it flashed across her mind that she had never before seen a rabbit with either a waistcoat-pocket, or a watch to take out of it, and burning with curiosity, she ran across the field after it, and fortunately was just in time to see it pop down a large rabbit-hole under the hedge."
}
]
};
+656
View File
@@ -0,0 +1,656 @@
import { Entity, Location } from "../../model/Model";
export const textF = `The reflection of the half moon danced on the rough waters of the Gulf of Mexico. Waves crashed against the rocky shore of Laguna Madre causing an ominous tower of white foam to rain down in a striking demonstration of the battle between sea and space. High tides had already swallowed the entirety of South Padre Island as a result of the exceptional gravitational pull of the black hole. Most had fled further inland in the forlorn hope of surviving the certain end of the world. But Mona and her father, Joseph, opted to surrender to the inevitable, and if time was kind, catch one final sunrise.
Mona developed her love of the sea from her dad, who was an oceanographer. They had spent many days exploring the coast and the Gulf of Mexico. She loved to discover starfish in tide pools and chase crabs in the sand. She loved the shapes kelp makes on the sand as they are washed up by the waves. Most of all, she loved to sit in the shallow waters of the ocean with Joseph. It always brought her great comfort to feel the warmth of the sun on her tanned shoulders and the sea spray lightly beat her feet and legs. Sadly, Parkinsons disease had robbed Joseph of his mobility and much of his voice. Mona, who cared for her father, wheeled him out each morning and evening to watch the sun rise and set over their beloved sea.
Their final sunset together had been tragic, yet awe-inspiring. The black hole did not swallow the sun in one gulp. Rather, the pull of the monster sucked in the matter of the star slowly. In the late afternoon, the outer edge of the sun bulged and looked like it might burst. By the time the sun set beneath the horizon, the matter sucked into the black hole formed a glowing, swirled tail glittering with stardust. A barrage of solar radiation destroyed the power grid. Twilight turned to a surreally dark night. With only the dimming light of the moon, Mona and Joseph were captivated by the millions of stars that shimmered in the most extraordinary night sky. The majestic spiral of the Milky Way shone brighter than they could have ever imagined.
The brilliance of the stars increased and the luster of the moon decreased as the intensity of the waves swelled. In an instant, the remaining moonlight was snuffed like a candle.
Mona squeezed her fathers hand. She gulped back tears. "I guess well miss the sunrise." Joseph nodded solemnly.
They sat quietly for some time. Mona focused on being present and tried to preserve every sensation she could: the roar of the waves, the coolness of the breeze on her face, the pungent smell of seaweed, the roughness of her fathers hand.
"Lets go," Joseph struggled to say. His hand trembled as he pointed to the water.
"You wanna move closer?"
He nodded. "Sit in the ocean."
Mona smiled. "Of course."
Mona wheeled Joseph over the bumpy terrain to the shore. She helped lower him to the ground and removed his shoes and socks. He closed his eyes and wiggled his toes in the wet sand as the choppy waters enveloped his legs. Mona sat down next to him and rested her head on his shoulder. She could feel his tremor as he put his arm around her. The briny water burned her eyes, and she savored the taste of salt on her lips.
The sun should have been rising. Instead, a bright disc of fire and cosmic particles peeked over the horizon in an eerie glow. Mona squinted at the shocking brightness. Joseph scooted into the water, turned around, and reached out his hands to Mona. She grabbed her dads hand and met his eyes. In his eye she could see the silhouette against the blazing remnants of the sun circling the black hole. As she lowered them both into the water, she recalled by the memory of his smiling face from the thousands of times they had swum in those very waters together.
"MONA? It's time to go back, you guys are going to catch a cold if you stay out there," her mother yelled from the car.
Mona looked to Joseph who was still entranced by the ocean and sunset.
"Just a few more moments..."\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n`;
export const dataTextF : {locations: Location[], entities: Entity[], actions: any[]} = {
entities: [
/*{
"name": "Gulf of Mexico",
"emoji": "🌊",
"properties": [
{
"name": "vast",
"value": 10
},
{
"name": "dynamic",
"value": 8
},
{
"name": "mysterious",
"value": 7
}
]
},*/
{
"name": "Moon",
"emoji": "🌙",
"properties": [
{
"name": "reflective",
"value": 8
},
{
"name": "waning",
"value": 7
},
{
"name": "serene",
"value": 6
}
]
},
/*{
"name": "South Padre Island",
"emoji": "🏖️",
"properties": [
{
"name": "submerged",
"value": 9
},
{
"name": "popular",
"value": 7
},
{
"name": "vulnerable",
"value": 8
}
]
},*/
/*{
"name": "Laguna Madre",
"emoji": "🏝️",
"properties": [
{
"name": "serene",
"value": 7
},
{
"name": "picturesque",
"value": 8
},
{
"name": "isolated",
"value": 6
}
]
},*/
{
"name": "Black Hole",
"emoji": "🌀",
"properties": [
{
"name": "powerful",
"value": 10
},
{
"name": "ominous",
"value": 9
},
{
"name": "mysterious",
"value": 10
}
]
},
{
"name": "Waves",
"emoji": "🌊",
"properties": [
{
"name": "crashing",
"value": 9
},
{
"name": "relentless",
"value": 8
},
{
"name": "rhythmic",
"value": 7
}
]
},
/*{
"name": "Stars",
"emoji": "✨",
"properties": [
{
"name": "dazzling",
"value": 9
},
{
"name": "numerous",
"value": 10
},
{
"name": "eternal",
"value": 8
}
]
},*/
{
"name": "Mona's Mother",
"emoji": "👩‍👧",
"properties": [
{
"name": "concerned",
"value": 8
},
{
"name": "protective",
"value": 9
},
{
"name": "practical",
"value": 7
}
]
},
{
"name": "Sun",
"emoji": "☀️",
"properties": [
{
"name": "brilliant",
"value": 10
},
{
"name": "vital",
"value": 9
},
{
"name": "diminishing",
"value": 7
}
]
},
{
"name": "Mona",
"emoji": "👩",
"properties": [
{
"name": "caring",
"value": 9
},
{
"name": "adventurous",
"value": 8
},
{
"name": "sentimental",
"value": 7
}
]
},
{
"name": "Joseph",
"emoji": "👴",
"properties": [
{
"name": "knowledgeable",
"value": 9
},
{
"name": "affectionate",
"value": 8
},
{
"name": "frail",
"value": 6
}
]
},
],
locations: [
{
"name": "Gulf of Mexico",
"emoji": "🌊"
},
{
"name": "Laguna Madre",
"emoji": "🏞️"
},
/*{
"name": "South Padre Island",
"emoji": "🏝️"
},*/
{
"name": "The Shore",
"emoji": "🏖️"
},
{
"name": "The Ocean",
"emoji": "🌊"
},
{
"name": "The Car",
"emoji": "🚗"
}
],
actions: [
{
"name": "dance",
"source": "Moon",
"target": "Waves",
"location": "Gulf of Mexico",
"passage": "The reflection of the half moon danced on the rough waters of the Gulf of Mexico."
},
{
"name": "crashed against",
"source": "Waves",
"target": "Laguna Madre",
"location": "The Shore",
"passage": "Waves crashed against the rocky shore of Laguna Madre causing an ominous tower of white foam to rain down in a striking demonstration of the battle between sea and space."
},
{
"name": "swallowed",
"source": "High tides",
"target": "High tides",
"location": "South Padre Island",
"passage": "High tides had already swallowed the entirety of South Padre Island as a result of the exceptional gravitational pull of the black hole."
},
{
"name": "opted to surrender",
"source": "Mona",
"target": "Mona",
"location": "unknown",
"passage": "But Mona and her father, Joseph, opted to surrender to the inevitable, and if time was kind, catch one final sunrise."
},
{
"name": "opted to surrender",
"source": "Joseph",
"target": "Joseph",
"location": "unknown",
"passage": "But Mona and her father, Joseph, opted to surrender to the inevitable, and if time was kind, catch one final sunrise."
},
{
"name": "catch sunrise",
"source": "Mona",
"target": "Sun",
"location": "unknown",
"passage": "But Mona and her father, Joseph, opted to surrender to the inevitable, and if time was kind, catch one final sunrise."
},
{
"name": "catch sunrise",
"source": "Joseph",
"target": "Sun",
"location": "unknown",
"passage": "But Mona and her father, Joseph, opted to surrender to the inevitable, and if time was kind, catch one final sunrise."
},
{
"name": "developed love",
"source": "Mona",
"target": "Mona",
"location": "unknown",
"passage": "Mona developed her love of the sea from her dad, who was an oceanographer."
},
{
"name": "explore coast",
"source": "Mona",
"target": "Mona",
"location": "Gulf of Mexico",
"passage": "They had spent many days exploring the coast and the Gulf of Mexico."
},
{
"name": "explore coast",
"source": "Joseph",
"target": "Joseph",
"location": "Gulf of Mexico",
"passage": "They had spent many days exploring the coast and the Gulf of Mexico."
},
{
"name": "discover starfish",
"source": "Mona",
"target": "Mona",
"location": "Gulf of Mexico",
"passage": "She loved to discover starfish in tide pools and chase crabs in the sand."
},
{
"name": "chase crabs",
"source": "Mona",
"target": "Mona",
"location": "Gulf of Mexico",
"passage": "She loved to discover starfish in tide pools and chase crabs in the sand."
},
{
"name": "sit",
"source": "Mona",
"target": "Mona",
"location": "The Ocean",
"passage": "Most of all, she loved to sit in the shallow waters of the ocean with Joseph."
},
{
"name": "sit",
"source": "Joseph",
"target": "Joseph",
"location": "The Ocean",
"passage": "Most of all, she loved to sit in the shallow waters of the ocean with Joseph."
},
{
"name": "feel warmth",
"source": "Mona",
"target": "Sun",
"location": "The Ocean",
"passage": "It always brought her great comfort to feel the warmth of the sun on her tanned shoulders and the sea spray lightly beat her feet and legs."
},
{
"name": "wheel out",
"source": "Mona",
"target": "Joseph",
"location": "unknown",
"passage": "Mona, who cared for her father, wheeled him out each morning and evening to watch the sun rise and set over their beloved sea."
},
{
"name": "watch",
"source": "Mona",
"target": "Sun",
"location": "unknown",
"passage": "Mona, who cared for her father, wheeled him out each morning and evening to watch the sun rise and set over their beloved sea."
},
{
"name": "watch",
"source": "Joseph",
"target": "Sun",
"location": "unknown",
"passage": "Mona, who cared for her father, wheeled him out each morning and evening to watch the sun rise and set over their beloved sea."
},
{
"name": "sucked in",
"source": "Black Hole",
"target": "Sun",
"location": "unknown",
"passage": "Rather, the pull of the monster sucked in the matter of the star slowly."
},
{
"name": "set beneath",
"source": "Sun",
"target": "Sun",
"location": "unknown",
"passage": "By the time the sun set beneath the horizon, the matter sucked into the black hole formed a glowing, swirled tail glittering with stardust."
},
{
"name": "sucked in",
"source": "Black Hole",
"target": "Black Hole",
"location": "unknown",
"passage": "By the time the sun set beneath the horizon, the matter sucked into the black hole formed a glowing, swirled tail glittering with stardust."
},
{
"name": "captivated",
"source": "Mona",
"target": "Mona",
"location": "unknown",
"passage": "With only the dimming light of the moon, Mona and Joseph were captivated by the millions of stars that shimmered in the most extraordinary night sky."
},
{
"name": "captivated",
"source": "Joseph",
"target": "Joseph",
"location": "unknown",
"passage": "With only the dimming light of the moon, Mona and Joseph were captivated by the millions of stars that shimmered in the most extraordinary night sky."
},
{
"name": "squeezed hand",
"source": "Mona",
"target": "Joseph",
"location": "unknown",
"passage": "Mona squeezed her fathers hand."
},
{
"name": "gulped back",
"source": "Mona",
"target": "Mona",
"location": "unknown",
"passage": "She gulped back tears."
},
{
"name": "nodded solemnly",
"source": "Joseph",
"target": "Joseph",
"location": "unknown",
"passage": "Joseph nodded solemnly."
},
{
"name": "sat quietly",
"passage": "They sat quietly for some time.",
"location": "unknown",
"source": "Mona",
"target": "Mona",
},
{
"name": "sat quietly",
"passage": "They sat quietly for some time.",
"location": "unknown",
"source": "Joseph",
"target": "Joseph",
},
{
"name": "focused on",
"source": "Mona",
"target": "Mona",
"location": "unknown",
"passage": "Mona focused on being present and tried to preserve every sensation she could: the roar of the waves, the coolness of the breeze on her face, the pungent smell of seaweed, the roughness of her fathers hand."
},
{
"name": "tried to preserve",
"source": "Mona",
"target": "Mona",
"location": "unknown",
"passage": "Mona focused on being present and tried to preserve every sensation she could: the roar of the waves, the coolness of the breeze on her face, the pungent smell of seaweed, the roughness of her fathers hand."
},
{
"name": "struggled to say",
"source": "Joseph",
"target": "Mona",
"location": "unknown",
"passage": "\"Lets go,\" Joseph struggled to say."
},
{
"name": "hand trembled",
"source": "Joseph",
"target": "Joseph",
"location": "unknown",
"passage": "His hand trembled as he pointed to the water."
},
{
"name": "pointed",
"source": "Joseph",
"target": "Joseph",
"location": "unknown",
"passage": "His hand trembled as he pointed to the water."
},
{
"name": "nodded",
"source": "Joseph",
"target": "Joseph",
"location": "unknown",
"passage": "He nodded."
},
{
"name": "smile",
"source": "Mona",
"target": "Mona",
"location": "unknown",
"passage": "Mona smiled."
},
{
"name": "wheel over",
"source": "Mona",
"target": "Joseph",
"location": "The Shore",
"passage": "Mona wheeled Joseph over the bumpy terrain to the shore."
},
{
"name": "helped lower",
"source": "Mona",
"target": "Joseph",
"location": "The Shore",
"passage": "She helped lower him to the ground and removed his shoes and socks."
},
{
"name": "removed shoes",
"source": "Mona",
"target": "Joseph",
"location": "The Shore",
"passage": "She helped lower him to the ground and removed his shoes and socks."
},
{
"name": "removed socks",
"source": "Mona",
"target": "Joseph",
"location": "The Shore",
"passage": "She helped lower him to the ground and removed his shoes and socks."
},
{
"name": "closed eyes",
"source": "Joseph",
"target": "Joseph",
"location": "The Shore",
"passage": "He closed his eyes and wiggled his toes in the wet sand as the choppy waters enveloped his legs."
},
{
"name": "wiggled toes",
"source": "Joseph",
"target": "Joseph",
"location": "The Shore",
"passage": "He closed his eyes and wiggled his toes in the wet sand as the choppy waters enveloped his legs."
},
{
"name": "enveloped legs",
"source": "Waves",
"target": "Joseph",
"location": "The Shore",
"passage": "He closed his eyes and wiggled his toes in the wet sand as the choppy waters enveloped his legs."
},
{
"name": "sat down",
"source": "Mona",
"target": "Mona",
"location": "The Shore",
"passage": "Mona sat down next to him and rested her head on his shoulder."
},
{
"name": "rested head",
"source": "Mona",
"target": "Joseph",
"location": "The Shore",
"passage": "Mona sat down next to him and rested her head on his shoulder."
},
{
"name": "feel tremor",
"source": "Mona",
"target": "Joseph",
"location": "The Shore",
"passage": "She could feel his tremor as he put his arm around her."
},
{
"name": "put arm",
"source": "Joseph",
"target": "Mona",
"location": "The Shore",
"passage": "She could feel his tremor as he put his arm around her."
},
{
"name": "burned",
"source": "briny water",
"target": "Mona",
"location": "unknown",
"passage": "The briny water burned her eyes, and she savored the taste of salt on her lips."
},
{
"name": "savored",
"source": "Mona",
"target": "Mona",
"location": "unknown",
"passage": "The briny water burned her eyes, and she savored the taste of salt on her lips."
},
{
"name": "squinted",
"source": "Mona",
"target": "Mona",
"location": "unknown",
"passage": "Mona squinted at the shocking brightness."
},
{
"name": "scooted into",
"source": "Joseph",
"target": "Joseph",
"location": "The Ocean",
"passage": "Joseph scooted into the water, turned around, and reached out his hands to Mona."
},
{
"name": "turned around",
"source": "Joseph",
"target": "Joseph",
"location": "The Ocean",
"passage": "Joseph scooted into the water, turned around, and reached out his hands to Mona."
},
{
"name": "reached out",
"source": "Joseph",
"target": "Mona",
"location": "The Ocean",
"passage": "Joseph scooted into the water, turned around, and reached out his hands to Mona."
},
{
"name": "grabbed hand",
"source": "Mona",
"target": "Joseph",
"location": "The Ocean",
"passage": "She grabbed her dads hand and met his eyes."
},
{
"name": "met eyes",
"source": "Mona",
"target": "Joseph",
"location": "The Ocean",
"passage": "She grabbed her dads hand and met his eyes."
},
{
"name": "see silhouette",
"source": "Mona",
"target": "Sun",
"location": "unknown",
"passage": "In his eye she could see the silhouette against the blazing remnants of the sun circling the black hole."
},
{
"name": "lower into water",
"source": "Mona",
"target": "Mona",
"location": "The Ocean",
"passage": "As she lowered them both into the water, she recalled by the memory of his smiling face from the thousands of times they had swum in those very waters together."
},
{
"name": "yelled from",
"source": "Mona's Mother",
"target": "Mona",
"location": "The Car",
"passage": "It's time to go back, you guys are going to catch a cold if you stay out there,\" her mother yelled from the car."
},
{
"name": "looked",
"source": "Mona",
"target": "Joseph",
"location": "The Shore",
"passage": "Mona looked to Joseph who was still entranced by the ocean and sunset."
}
]
};
+920
View File
@@ -0,0 +1,920 @@
import { Entity, Location } from "../../model/Model";
export const textG = ` Little Angie never slept at night. Her parents worried. "Insomnia," the doctors said. "Is she anxious?" they asked.
Anxious? Can anyone imagine anyone less anxious than Angie? She was all giggles and sunburns. Her days were full of coloring books and earthworms. Shed hum songs to her cat or curl up on her moms lap and read fairy stories for hours.
Shed yawn at the breakfast table, and her snoozy eyes would droop while she lay in the field looking at clouds in the late afternoon sun, but she would never sleep.
"It is just a phase," said the doctors. "Shell outgrow it."
"But she wakes up with leaves in her hair," her mother said with a furrowed brow.
"And mud in her sheets," her father bemoaned.
"Angie, you must not wander at night," both parents would demand.
They locked her door and worried.
Angie loved her parents and wanted to be a good girl. But Angie could not sleep because she had a beautiful secret.
After Angie took her bath, washed all the dirt off of her scabby knees, and untangled all the straw and twigs from her curly hair, her mom and dad would come into her room, tuck her in, kiss her good night, and remind her to "stay in bed" and "try to sleep." Shed smile at them cheerfully and say, "Goodnight!" Then, as soon as her mom closed her bedroom door with a click, shed spring to her window and wait for Midnight Lightning to appear.
Midnight Lightning came every night. Angie would gleefully watch him approach from her bedroom window. First, hed appear as a brilliant, speeding blur on the distant horizon where the forest met the low field. Then, shed hear the thundering gallops of his hoofs hitting the ground with vibrant force. Shed see the moonlight reflecting an iridescent shimmer on his shining black coat.
Every night, he would come to Angies window, gently whinnying, calling her to join him on their magical, secret, nightly adventures. Shed open her window. Barefoot in her nightgown, shed climb down the trellis to where he waited, swishing his long, jet-black tail. Hed joyfully huff when she joined him, then lower his head and gently rub his velvet-soft muzzle on Angies cheek. Shed take in a deep breath of his good, pure horsey scent and kiss his nose. Then, shed climb up on the haystack (which she kept under her window for this exact purpose) and throw a leg over Midnight Lightnings tall back. Shed link her fingers through his strong, silky mane. Angie and the magical horse would become one.
Hed take off with a wild gallop, and shed fluidly follow his every movement. Soon, theyd be racing through the fields, watching as fireflies sprung up all around them, dancing alongside them in the moonlight. Theyd stride through starlit forests, listening to the cicadas and crickets sing and would join in their song. Theyd splash through clear streams, relishing the bright, cool of the mountain-fed waters.
Sometimes theyd visit the wise old owls of the woods. "Too-who?" the owls would ask, turning their large eyes to gaze at the pair. Midnight Lightning neighed a response that only he and the other enchanted animals could understand.
Sometimes, theyd dash to the summits of distant mountains, looking down on the moonlit vista of the villages beneath them. Angie would point to her own little home below.
Sometimes, theyd explore hidden caves, luminous with glowworms. Theyd trot through the dark caverns and admire the crystals that shone brilliantly in the bioluminescent light.
Midnight Lightning always had precious secret places to share with Angie. Every night was new. She was never sure where Midnight Lightning might lead her, but she always knew she was safe and loved.
When the first hint of dawn began to break in the east, Midnight Lightning would return Angie to her bedroom window. Shed unlace her fingers from his mane and slide off his strong back. Shed kiss his downy nose. With windblown hair and muddy toes, shed climb back into her bedroom. Smiling, shed slip under her covers and close her eyes as she listened to Midnight Lightning galloping away while the first gleams of morning light shone through her window.
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n`;
export const dataTextG : {locations: Location[], entities: Entity[], actions: any[]} = {
entities: [
{
"name": "Angie's Door",
"emoji": "🚪",
"properties": [
{
"name": "locked",
"value": 8
},
{
"name": "secure",
"value": 9
},
{
"name": "protective",
"value": 8
}
]
},
{
"name": "Angie's Dad",
"emoji": "👨",
"properties": [
{
"name": "concerned",
"value": 8
},
{
"name": "loving",
"value": 9
},
{
"name": "protective",
"value": 8
}
]
},
{
"name": "Angie's Mom",
"emoji": "👩",
"properties": [
{
"name": "concerned",
"value": 8
},
{
"name": "loving",
"value": 9
},
{
"name": "protective",
"value": 8
}
]
},
{
"name": "Doctors",
"emoji": "👨‍⚕️",
"properties": [
{
"name": "concerned",
"value": 8
},
{
"name": "professional",
"value": 9
},
{
"name": "knowledgeable",
"value": 8
}
]
},
/*{
"name": "The Caves",
"emoji": "🕳️",
"properties": [
{
"name": "hidden",
"value": 8
},
{
"name": "luminous",
"value": 9
},
{
"name": "mysterious",
"value": 9
}
]
},*/
/*{
"name": "The Forest",
"emoji": "🌲",
"properties": [
{
"name": "mystical",
"value": 9
},
{
"name": "serene",
"value": 8
},
{
"name": "enchanting",
"value": 9
}
]
},*/
{
"name": "Midnight Lightning",
"emoji": "🐎",
"properties": [
{
"name": "magical",
"value": 10
},
{
"name": "swift",
"value": 10
},
{
"name": "loyal",
"value": 9
}
]
},
{
"name": "Angie",
"emoji": "👧",
"properties": [
{
"name": "cheerful",
"value": 9
},
{
"name": "adventurous",
"value": 10
},
{
"name": "imaginative",
"value": 10
}
]
},
/*{
"name": "Enchanted Animals",
"emoji": "🦄",
"properties": [
{
"name": "wise",
"value": 9
},
{
"name": "curious",
"value": 7
},
{
"name": "mysterious",
"value": 8
}
]
},*/
{
"name": "the Wise Old Owls",
"emoji": "🦉",
"properties": [
{
"name": "wise",
"value": 9
},
{
"name": "curious",
"value": 7
},
{
"name": "mysterious",
"value": 8
}
]
},
/*,
{
"name": "The Fields",
"emoji": "🌾",
"properties": [
{
"name": "expansive",
"value": 8
},
{
"name": "peaceful",
"value": 7
},
{
"name": "vibrant",
"value": 8
}
]
}*/
],
locations: [
{
"name": "Fields",
"emoji": "🌾"
},
{
"name": "Starlit Forests",
"emoji": "🌲"
},
{
"name": "Clear Streams",
"emoji": "🏞️"
},
{
"name": "Wise Old Owls' Woods",
"emoji": "🦉"
},
{
"name": "Distant Mountain Summits",
"emoji": "⛰️"
},
{
"name": "Hidden Caves",
"emoji": "🕳️"
},
{
"name": "Breakfast Table",
"emoji": "🍳"
},
{
"name": "Angie's Bedroom",
"emoji": "🛏️"
}
],
actions: [
{
"name": "never slept",
"source": "Angie",
"target": "Angie",
"location": "unknown",
"passage": "Little Angie never slept at night."
},
{
"name": "worried",
"source": "Angie's Mom",
"target": "Angie's Mom",
"location": "unknown",
"passage": "Her parents worried."
},
{
"name": "worried",
"source": "Angie's Dad",
"target": "Angie's Dad",
"location": "unknown",
"passage": "Her parents worried."
},
{
"name": "said",
"source": "Doctors",
"target": "Angie's Mom",
"location": "unknown",
"passage": "\"Insomnia,\" the doctors said."
},
{
"name": "said",
"source": "Doctors",
"target": "Angie's Dad",
"location": "unknown",
"passage": "\"Insomnia,\" the doctors said."
},
{
"name": "coloring",
"source": "Angie",
"target": "Angie",
"location": "unknown",
"passage": "Her days were full of coloring books and earthworms."
},
{
"name": "hum songs",
"source": "Angie",
"target": "Angie",
"location": "unknown",
"passage": "Shed hum songs to her cat or curl up on her moms lap and read fairy stories for hours."
},
{
"name": "curl up",
"source": "Angie",
"target": "Angie's Mom",
"location": "unknown",
"passage": "Shed hum songs to her cat or curl up on her moms lap and read fairy stories for hours."
},
{
"name": "read stories",
"source": "Angie",
"target": "Angie",
"location": "unknown",
"passage": "Shed hum songs to her cat or curl up on her moms lap and read fairy stories for hours."
},
{
"name": "yawn",
"source": "Angie",
"target": "Angie",
"location": "Breakfast Table",
"passage": "Shed yawn at the breakfast table, and her snoozy eyes would droop while she lay in the field looking at clouds in the late afternoon sun, but she would never sleep."
},
{
"name": "droop",
"source": "Angie",
"target": "Angie",
"location": "Fields",
"passage": "Shed yawn at the breakfast table, and her snoozy eyes would droop while she lay in the field looking at clouds in the late afternoon sun, but she would never sleep."
},
{
"name": "lay",
"source": "Angie",
"target": "Angie",
"location": "Fields",
"passage": "Shed yawn at the breakfast table, and her snoozy eyes would droop while she lay in the field looking at clouds in the late afternoon sun, but she would never sleep."
},
{
"name": "said ",
"source": "Doctors",
"target": "Angie's Dad",
"location": "unknown",
"passage": "\"It is just a phase,\" said the doctors."
},
{
"name": "said ",
"source": "Doctors",
"target": "Angie's Mom",
"location": "unknown",
"passage": "\"It is just a phase,\" said the doctors."
},
{
"name": "said",
"source": "Angie's Mom",
"target": "Doctors",
"location": "unknown",
"passage": "\"But she wakes up with leaves in her hair,\" her mother said with a furrowed brow."
},
{
"name": "bemoaned",
"source": "Angie's Dad",
"target": "Doctors",
"location": "unknown",
"passage": "\"And mud in her sheets,\" her father bemoaned."
},
{
"name": "demand",
"source": "Angie's Mom",
"target": "Angie",
"location": "Angie's Bedroom",
"passage": "\"Angie, you must not wander at night,\" both parents would demand."
},
{
"name": "demand",
"source": "Angie's Dad",
"target": "Angie",
"location": "Angie's Bedroom",
"passage": "\"Angie, you must not wander at night,\" both parents would demand."
},
{
"name": "lock door",
"source": "Angie's Mom",
"target": "Angie's Door",
"location": "Angie's Bedroom",
"passage": "They locked her door and worried."
},
{
"name": "lock door",
"source": "Angie's Dad",
"target": "Angie's Door",
"location": "Angie's Bedroom",
"passage": "They locked her door and worried."
},
{
"name": "worried ",
"source": "Angie's Dad",
"target": "Angie's Dad",
"location": "Angie's Bedroom",
"passage": "They locked her door and worried."
},
{
"name": "worried ",
"source": "Angie's Mom",
"target": "Angie's Mom",
"location": "Angie's Bedroom",
"passage": "They locked her door and worried."
},
{
"name": "loved",
"source": "Angie",
"target": "Angie's Mom",
"location": "unknown",
"passage": "Angie loved her parents and wanted to be a good girl."
},
{
"name": "take bath",
"source": "Angie",
"target": "Angie",
"location": "unknown",
"passage": "After Angie took her bath, washed all the dirt off of her scabby knees, and untangled all the straw and twigs from her curly hair, her mom and dad would come into her room, tuck her in, kiss her good night, and remind her to \"stay in bed\" and \"try to sleep."
},
{
"name": "wash knees",
"source": "Angie",
"target": "Angie",
"location": "unknown",
"passage": "After Angie took her bath, washed all the dirt off of her scabby knees, and untangled all the straw and twigs from her curly hair, her mom and dad would come into her room, tuck her in, kiss her good night, and remind her to \"stay in bed\" and \"try to sleep."
},
{
"name": "untangle hair",
"source": "Angie",
"target": "Angie",
"location": "unknown",
"passage": "After Angie took her bath, washed all the dirt off of her scabby knees, and untangled all the straw and twigs from her curly hair, her mom and dad would come into her room, tuck her in, kiss her good night, and remind her to \"stay in bed\" and \"try to sleep."
},
{
"name": "tuck in",
"source": "Angie's Mom",
"target": "Angie",
"location": "Angie's Bedroom",
"passage": "After Angie took her bath, washed all the dirt off of her scabby knees, and untangled all the straw and twigs from her curly hair, her mom and dad would come into her room, tuck her in, kiss her good night, and remind her to \"stay in bed\" and \"try to sleep."
},
{
"name": "tuck in",
"source": "Angie's Dad",
"target": "Angie",
"location": "Angie's Bedroom",
"passage": "After Angie took her bath, washed all the dirt off of her scabby knees, and untangled all the straw and twigs from her curly hair, her mom and dad would come into her room, tuck her in, kiss her good night, and remind her to \"stay in bed\" and \"try to sleep."
},
{
"name": "kiss good night",
"source": "Angie's Mom",
"target": "Angie",
"location": "Angie's Bedroom",
"passage": "After Angie took her bath, washed all the dirt off of her scabby knees, and untangled all the straw and twigs from her curly hair, her mom and dad would come into her room, tuck her in, kiss her good night, and remind her to \"stay in bed\" and \"try to sleep."
},
{
"name": "kiss good night",
"source": "Angie's Dad",
"target": "Angie",
"location": "Angie's Bedroom",
"passage": "After Angie took her bath, washed all the dirt off of her scabby knees, and untangled all the straw and twigs from her curly hair, her mom and dad would come into her room, tuck her in, kiss her good night, and remind her to \"stay in bed\" and \"try to sleep."
},
{
"name": "remind to stay",
"source": "Angie's Mom",
"target": "Angie",
"location": "Angie's Bedroom",
"passage": "After Angie took her bath, washed all the dirt off of her scabby knees, and untangled all the straw and twigs from her curly hair, her mom and dad would come into her room, tuck her in, kiss her good night, and remind her to \"stay in bed\" and \"try to sleep."
},
{
"name": "remind to stay",
"source": "Angie's Dad",
"target": "Angie",
"location": "Angie's Bedroom",
"passage": "After Angie took her bath, washed all the dirt off of her scabby knees, and untangled all the straw and twigs from her curly hair, her mom and dad would come into her room, tuck her in, kiss her good night, and remind her to \"stay in bed\" and \"try to sleep."
},
{
"name": "remind to sleep",
"source": "Angie's Mom",
"target": "Angie",
"location": "Angie's Bedroom",
"passage": "After Angie took her bath, washed all the dirt off of her scabby knees, and untangled all the straw and twigs from her curly hair, her mom and dad would come into her room, tuck her in, kiss her good night, and remind her to \"stay in bed\" and \"try to sleep."
},
{
"name": "remind to sleep",
"source": "Angie's Dad",
"target": "Angie",
"location": "Angie's Bedroom",
"passage": "After Angie took her bath, washed all the dirt off of her scabby knees, and untangled all the straw and twigs from her curly hair, her mom and dad would come into her room, tuck her in, kiss her good night, and remind her to \"stay in bed\" and \"try to sleep."
},
{
"name": "close door",
"source": "Angie's Mom",
"target": "Angie's Door",
"location": "Angie's Bedroom",
"passage": "\" Then, as soon as her mom closed her bedroom door with a click, shed spring to her window and wait for Midnight Lightning to appear."
},
{
"name": "spring to window",
"source": "Angie",
"target": "Angie",
"location": "Angie's Bedroom",
"passage": "\" Then, as soon as her mom closed her bedroom door with a click, shed spring to her window and wait for Midnight Lightning to appear."
},
{
"name": "wait for",
"source": "Angie",
"target": "Midnight Lightning",
"location": "Angie's Bedroom",
"passage": "\" Then, as soon as her mom closed her bedroom door with a click, shed spring to her window and wait for Midnight Lightning to appear."
},
{
name: "watch approach",
source: "Angie",
target: "Midnight Lightning",
location: "Angie's Bedroom",
passage: "Angie would gleefully watch him approach from her bedroom window."
},
{
"name": "appear",
"source": "Midnight Lightning",
"target": "Angie",
"location": "unknown",
"passage": "First, hed appear as a brilliant, speeding blur on the distant horizon where the forest met the low field."
},
{
"name": "hear gallops",
"source": "Angie",
"target": "Midnight Lightning",
"location": "Angie's Bedroom",
"passage": "Then, shed hear the thundering gallops of his hoofs hitting the ground with vibrant force."
},
{
"name": "reflecting",
"source": "The Moonlight",
"target": "Midnight Lightning",
"location": "unknown",
"passage": "Shed see the moonlight reflecting an iridescent shimmer on his shining black coat."
},
{
"name": "come to window",
"source": "Midnight Lightning",
"target": "Angie",
"location": "Angie's Bedroom",
"passage": "Every night, he would come to Angies window, gently whinnying, calling her to join him on their magical, secret, nightly adventures."
},
{
"name": "whinnying",
"source": "Midnight Lightning",
"target": "Angie",
"location": "Angie's Bedroom",
"passage": "Every night, he would come to Angies window, gently whinnying, calling her to join him on their magical, secret, nightly adventures."
},
{
"name": "calling her",
"source": "Midnight Lightning",
"target": "Angie",
"location": "Angie's Bedroom",
"passage": "Every night, he would come to Angies window, gently whinnying, calling her to join him on their magical, secret, nightly adventures."
},
{
"name": "open window",
"source": "Angie",
"target": "Angie",
"location": "Angie's Bedroom",
"passage": "Shed open her window."
},
{
"name": "climb down",
"source": "Angie",
"target": "Angie",
"location": "unknown",
"passage": "Barefoot in her nightgown, shed climb down the trellis to where he waited, swishing his long, jet-black tail."
},
{
"name": "waited",
"source": "Midnight Lightning",
"target": "Midnight Lightning",
"location": "unknown",
"passage": "Barefoot in her nightgown, shed climb down the trellis to where he waited, swishing his long, jet-black tail."
},
{
"name": "swishing tail",
"source": "Midnight Lightning",
"target": "Midnight Lightning",
"location": "unknown",
"passage": "Barefoot in her nightgown, shed climb down the trellis to where he waited, swishing his long, jet-black tail."
},
{
"name": "huff joyfully",
"source": "Midnight Lightning",
"target": "Midnight Lightning",
"location": "unknown",
"passage": "Hed joyfully huff when she joined him, then lower his head and gently rub his velvet-soft muzzle on Angies cheek."
},
{
"name": "lower head",
"source": "Midnight Lightning",
"target": "Midnight Lightning",
"location": "unknown",
"passage": "Hed joyfully huff when she joined him, then lower his head and gently rub his velvet-soft muzzle on Angies cheek."
},
{
"name": "rub muzzle",
"source": "Midnight Lightning",
"target": "Angie",
"location": "unknown",
"passage": "Hed joyfully huff when she joined him, then lower his head and gently rub his velvet-soft muzzle on Angies cheek."
},
{
"name": "take breath",
"source": "Angie",
"target": "Angie",
"location": "unknown",
"passage": "Shed take in a deep breath of his good, pure horsey scent and kiss his nose."
},
{
"name": "kiss nose",
"source": "Angie",
"target": "Midnight Lightning",
"location": "unknown",
"passage": "Shed take in a deep breath of his good, pure horsey scent and kiss his nose."
},
{
"name": "climb up",
"source": "Angie",
"target": "Angie",
"location": "unknown",
"passage": "Then, shed climb up on the haystack (which she kept under her window for this exact purpose) and throw a leg over Midnight Lightnings tall back."
},
{
"name": "throw leg",
"source": "Angie",
"target": "Midnight Lightning",
"location": "unknown",
"passage": "Then, shed climb up on the haystack (which she kept under her window for this exact purpose) and throw a leg over Midnight Lightnings tall back."
},
{
"name": "link fingers",
"source": "Angie",
"target": "Midnight Lightning",
"location": "unknown",
"passage": "Shed link her fingers through his strong, silky mane."
},
{
"name": "take off",
"source": "Midnight Lightning",
"target": "Midnight Lightning",
"location": "unknown",
"passage": "Hed take off with a wild gallop, and shed fluidly follow his every movement."
},
{
"name": "follow movement",
"source": "Angie",
"target": "Midnight Lightning",
"location": "unknown",
"passage": "Hed take off with a wild gallop, and shed fluidly follow his every movement."
},
{
"name": "racing",
"source": "Angie",
"target": "Midnight Lightning",
"location": "Fields",
"passage": "Soon, theyd be racing through the fields, watching as fireflies sprung up all around them, dancing alongside them in the moonlight."
},
{
"name": "watching",
"source": "Angie",
"target": "Angie",
"location": "Fields",
"passage": "Soon, theyd be racing through the fields, watching as fireflies sprung up all around them, dancing alongside them in the moonlight."
},
{
"name": "springing up",
"source": "The Fields",
"target": "The Fields",
"location": "Fields",
"passage": "Soon, theyd be racing through the fields, watching as fireflies sprung up all around them, dancing alongside them in the moonlight."
},
{
"name": "dancing",
"source": "The Fields",
"target": "The Fields",
"location": "Fields",
"passage": "Soon, theyd be racing through the fields, watching as fireflies sprung up all around them, dancing alongside them in the moonlight."
},
{
"name": "stride through",
"source": "Angie",
"target": "Angie",
"location": "Starlit Forests",
"passage": "Theyd stride through starlit forests, listening to the cicadas and crickets sing and would join in their song."
},
{
"name": "stride through",
"source": "Midnight Lightning",
"target": "Midgnight Lightning",
"location": "Starlit Forests",
"passage": "Theyd stride through starlit forests, listening to the cicadas and crickets sing and would join in their song."
},
{
"name": "listening",
"source": "Angie",
"target": "Angie",
"location": "Starlit Forests",
"passage": "Theyd stride through starlit forests, listening to the cicadas and crickets sing and would join in their song."
},
{
"name": "listening",
"source": "Midnight Lightning",
"target": "Midnight Lightning",
"location": "Starlit Forests",
"passage": "Theyd stride through starlit forests, listening to the cicadas and crickets sing and would join in their song."
},
{
"name": "splash through",
"source": "Angie",
"target": "Angie",
"location": "Clear Streams",
"passage": "Theyd splash through clear streams, relishing the bright, cool of the mountain-fed waters."
},
{
"name": "splash through",
"source": "Midnight Lightning",
"target": "Midnight Lightning",
"location": "Clear Streams",
"passage": "Theyd splash through clear streams, relishing the bright, cool of the mountain-fed waters."
},
{
"name": "relish",
"source": "Angie",
"target": "Angie",
"location": "Clear Streams",
"passage": "Theyd splash through clear streams, relishing the bright, cool of the mountain-fed waters."
},
{
"name": "relish",
"source": "Midnight Lightning",
"target": "Midnight Lightning",
"location": "Clear Streams",
"passage": "Theyd splash through clear streams, relishing the bright, cool of the mountain-fed waters."
},
{
"name": "visit",
"source": "Angie",
"target": "the Wise Old Owls",
"location": "Wise Old Owls' Woods",
"passage": "Sometimes theyd visit the wise old owls of the woods."
},
{
"name": "visit",
"source": "Midnight Lightning",
"target": "the Wise Old Owls",
"location": "Wise Old Owls' Woods",
"passage": "Sometimes theyd visit the wise old owls of the woods."
},
{
"name": "ask",
"source": "the Wise Old Owls",
"target": "Angie",
"location": "Wise Old Owls' Woods",
"passage": "\"Too-who?\" the owls would ask, turning their large eyes to gaze at the pair."
},
{
"name": "ask",
"source": "the Wise Old Owls",
"target": "Midnight Lightning",
"location": "Wise Old Owls' Woods",
"passage": "\"Too-who?\" the owls would ask, turning their large eyes to gaze at the pair."
},
{
"name": "gaze",
"source": "the Wise Old Owls",
"target": "Angie",
"location": "Wise Old Owls' Woods",
"passage": "\"Too-who?\" the owls would ask, turning their large eyes to gaze at the pair."
},
{
"name": "gaze",
"source": "the Wise Old Owls",
"target": "Midnight Lightning",
"location": "Wise Old Owls' Woods",
"passage": "\"Too-who?\" the owls would ask, turning their large eyes to gaze at the pair."
},
{
"name": "neighed response",
"source": "Midnight Lightning",
"target": "The Wise Old Owls",
"location": "Wise Old Owls' Woods",
"passage": "Midnight Lightning neighed a response that only he and the other enchanted animals could understand."
},
{
"name": "dash",
"source": "Angie",
"target": "Angie",
"location": "Distant Mountain Summits",
"passage": "Sometimes, theyd dash to the summits of distant mountains, looking down on the moonlit vista of the villages beneath them."
},
{
"name": "dash",
"source": "Midnight Lightning",
"target": "Midnight Lightning",
"location": "Distant Mountain Summits",
"passage": "Sometimes, theyd dash to the summits of distant mountains, looking down on the moonlit vista of the villages beneath them."
},
{
"name": "point",
"source": "Angie",
"target": "Angie",
"location": "Distant Mountain Summits",
"passage": "Sometimes, theyd dash to the summits of distant mountains, looking down on the moonlit vista of the villages beneath them."
},
{
"name": "explore caves",
"source": "Angie",
"target": "Angie",
"location": "Hidden Caves",
"passage": "Sometimes, theyd explore hidden caves, luminous with glowworms."
},
{
"name": "explore caves",
"source": "Midnight Lightning",
"target": "Midnight Lightning",
"location": "Hidden Caves",
"passage": "Sometimes, theyd explore hidden caves, luminous with glowworms."
},
{
"name": "trot",
"source": "Midnight Lightning",
"target": "Midnight Lightning",
"location": "Hidden Caves",
"passage": "Theyd trot through the dark caverns and admire the crystals that shone brilliantly in the bioluminescent light."
},
{
"name": "admire",
"source": "Angie",
"target": "Angie",
"location": "Hidden Caves",
"passage": "Theyd trot through the dark caverns and admire the crystals that shone brilliantly in the bioluminescent light."
},
{
"name": "admire",
"source": "Midnight Lightning",
"target": "Midnight Lightning",
"location": "Hidden Caves",
"passage": "Theyd trot through the dark caverns and admire the crystals that shone brilliantly in the bioluminescent light."
},
{
"name": "return Angie",
"source": "Midnight Lightning",
"target": "Angie",
"location": "Angie's Bedroom",
"passage": "When the first hint of dawn began to break in the east, Midnight Lightning would return Angie to her bedroom window."
},
{
"name": "unlace fingers",
"source": "Angie",
"target": "Midnight Lightning",
"location": "Angie's Bedroom",
"passage": "Shed unlace her fingers from his mane and slide off his strong back."
},
{
"name": "slide off",
"source": "Angie",
"target": "Midnight Lightning",
"location": "Angie's Bedroom",
"passage": "Shed unlace her fingers from his mane and slide off his strong back."
},
{
"name": "kiss",
"source": "Angie",
"target": "Midnight Lightning",
"location": "Angie's Bedroom",
"passage": "Shed kiss his downy nose."
},
{
"name": "climb back",
"source": "Angie",
"target": "Angie",
"location": "Angie's Bedroom",
"passage": "With windblown hair and muddy toes, shed climb back into her bedroom."
},
{
"name": "slip under",
"source": "Angie",
"target": "Angie",
"location": "Angie's Bedroom",
"passage": "Smiling, shed slip under her covers and close her eyes as she listened to Midnight Lightning galloping away while the first gleams of morning light shone through her window."
},
{
"name": "close eyes",
"source": "Angie",
"target": "Angie",
"location": "Angie's Bedroom",
"passage": "Smiling, shed slip under her covers and close her eyes as she listened to Midnight Lightning galloping away while the first gleams of morning light shone through her window."
},
{
"name": "listen to",
"source": "Angie",
"target": "Midnight Lightning",
"location": "Angie's Bedroom",
"passage": "Smiling, shed slip under her covers and close her eyes as she listened to Midnight Lightning galloping away while the first gleams of morning light shone through her window."
}
]
};
+79
View File
@@ -0,0 +1,79 @@
import { Button, Tooltip } from '@nextui-org/react';
import { HierarchyPointNode } from 'd3-hierarchy';
import { useCallback, useEffect } from 'react';
import Tree, { TreeNodeDatum } from 'react-d3-tree';
import { IoArrowRedo, IoArrowUndo } from 'react-icons/io5';
import { useHistoryModelStore } from '../model/HistoryModel';
import { useModelStore } from '../model/Model';
export default function HistoryTree() {
let historyTree = useHistoryModelStore(state => state.historyTree);
const positionInTree = useHistoryModelStore(state => state.positionInTree);
const redoStack = useHistoryModelStore(state => state.redoPositionStack);
// If the historyTree is empty, we add a state to represent we are at the root
useEffect(() => {
if (useHistoryModelStore.getState().historyTree === null) {
useHistoryModelStore.getState().addHistoryNode(useModelStore.getState());
}
}, []);
if (historyTree === null) {
// Quick fix to avoid empty tree
historyTree = {name: '', children: [], state: useModelStore.getState()};
}
const findPositionFromHieararchyPointNode = useCallback((nodeData: HierarchyPointNode<TreeNodeDatum>) => {
// Climb up the tree until the root to figure out the position of the node
let nodePosition : number[] = [];
let parent = nodeData.parent;
let currentNode = nodeData;
while (parent !== null && parent.children) {
const posWithinParent = parent.children.findIndex((child: any) => child === currentNode);
nodePosition.splice(0, 0, posWithinParent);
currentNode = parent;
parent = parent.parent;
}
return nodePosition;
}, []);
const onNodeClick = (position: number[], nodeData: HierarchyPointNode<TreeNodeDatum>) => {
useHistoryModelStore.getState().setPositionInTree(position);
}
return (
<div id="treeWrapper" style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', width: '100%', flexGrow: 0, maxHeight: 50, background: '#F3F4F6', borderTop: '1px solid #DDDDDF'}}>
<Tooltip content="Undo" closeDelay={0} placement='right'>
<Button isDisabled={positionInTree.length === 0} isIconOnly size='sm' style={{fontSize: 18, marginLeft: 5}} onClick={() => useHistoryModelStore.getState().undo()}><IoArrowUndo/></Button>
</Tooltip>
<Tooltip content="Redo" closeDelay={0} placement='right'>
<Button isDisabled={redoStack.length === 0} isIconOnly size='sm' style={{fontSize: 18, marginLeft: 5}} onClick={() => useHistoryModelStore.getState().redo()}><IoArrowRedo/></Button>
</Tooltip>
<Tree
data={historyTree}
collapsible={false}
draggable={false}
zoomable={false}
translate={{ x: 25, y: 25 }}
transitionDuration={500}
nodeSize={{ x: 20, y: 15 }}
orientation='horizontal'
renderCustomNodeElement={(nodeData) => {
const position = findPositionFromHieararchyPointNode(nodeData.hierarchyPointNode);
const isSelected = position.join(',') === positionInTree.join(',');
return <circle className='history-node' r={6} fill={isSelected ? '#326FEE' : '#6D6E6E'} strokeWidth={0}
onClick={() => onNodeClick(position, nodeData.hierarchyPointNode)}></circle>;
}}
/>
</div>
)
}
+143
View File
@@ -0,0 +1,143 @@
import { Button, Card, CardBody, CardHeader, Divider, Input, Select, SelectItem } from "@nextui-org/react";
import { useState } from "react";
import { MdHistoryEdu } from "react-icons/md";
import { useModelStore } from '../model/Model';
import { extractedEntitiesToNodeEntities } from "../model/prompts/textExtractors/EntitiesExtractor";
import { extractedLocationsToNodeLocations } from "../model/prompts/textExtractors/LocationsExtractor";
import { extractedActionsToEdgeActions } from "../model/prompts/textExtractors/SentenceActionsExtractor";
import { VisualRefresher } from "../model/prompts/textExtractors/VisualRefresher";
import { dataTextAlice, textAlice } from "../study/data/TextAlice";
import { dataTextB, textB } from "../study/data/TextB";
import { dataTextD, textD } from "../study/data/TextD";
import { useStudyStore } from "../study/StudyModel";
export default function Launcher() {
const [accessKey, setAccessKey] = useState('');
const [pid, setPid] = useState(-1);
const setOpenAIKey = useModelStore((state) => state.setOpenAIKey);
const resetModel = useModelStore((state) => state.reset);
const resetStudyModel = useStudyStore((state) => state.reset);
function startExample(text : string, data : any) {
resetModel();
resetStudyModel();
useModelStore.getState().setTextState([{ children: [{text: text }] }], true, false);
useModelStore.getState().setIsStale(false);
VisualRefresher.getInstance().previousText = useModelStore.getState().text;
VisualRefresher.getInstance().onUpdate();
if (data) {
const entityNodes = extractedEntitiesToNodeEntities(data);
const locationNodes = extractedLocationsToNodeLocations(data);
const actionEdges = data.actions.map((h : any) => extractedActionsToEdgeActions({actions: [h]}, h.passage, entityNodes)).flat();
useModelStore.getState().setEntityNodes(entityNodes);
useModelStore.getState().setLocationNodes(locationNodes);
useModelStore.getState().setActionEdges(actionEdges);
} else {
const locationNodes = extractedLocationsToNodeLocations({
locations: [{
name: "unknown",
emoji: "🌍",
}]
});
useModelStore.getState().setLocationNodes(locationNodes);
useModelStore.getState().setEntityNodes([]);
useModelStore.getState().setActionEdges([]);
}
window.location.hash = '/free-form' + `?k=${btoa(accessKey)}`;
}
return <div style={{display: 'flex', flexDirection: 'row', justifyContent: 'center', alignItems: 'center', height: '100vh'}}>
<Card>
<CardHeader><span style={{fontSize: 25}}><MdHistoryEdu /></span><span style={{marginLeft: 5}}>Visual Story-Writing</span></CardHeader>
<Divider />
<CardBody>
<p>To run the examples below, please paste an OpenAI API key. You can obtain one from <a href="https://platform.openai.com/account/api-keys">here</a>.</p>
<Input variant="faded" label="API Key" placeholder="sk-..." style={{marginTop: 10}}
onChange={(e) => {
setAccessKey(e.target.value);
setOpenAIKey(e.target.value);
}}
></Input>
</CardBody>
<Divider />
<CardBody>
<span style={{fontWeight: 800}}>Shortcuts to try out Visual Story-Writing on examples</span>
<div style={{display: 'flex', flexDirection: 'row', justifyContent: 'center', alignItems: 'center', gap: 40, marginTop: 10}}>
<Button
isDisabled={accessKey.length === 0}
onClick={() => {
startExample(textAlice, dataTextAlice)
}}
>Alice in Wonderland</Button>
<Button
isDisabled={accessKey.length === 0}
onClick={() => {
startExample(textB, dataTextB)
}}
>Sled Adventure</Button>
<Button
isDisabled={accessKey.length === 0}
onClick={() => {
startExample(textD, dataTextD)
}}
>Waves Apart</Button>
<Button
isDisabled={accessKey.length === 0}
onClick={() => {
startExample("", null);
}}
>Blank Page</Button>
</div>
</CardBody>
<Divider />
<CardBody>
<span style={{fontWeight: 800}}>Run study 1</span>
<div style={{display: 'flex', flexDirection: 'row', justifyContent: 'left', alignItems: 'center', gap: 40, marginTop: 10}}>
<Select isDisabled={accessKey.length === 0}
variant="faded" label="Participant ID" className="max-w-xs"
onChange={(e) => setPid(parseInt(e.target.value))}>
{
Array.from({length: 12}, (_, i) => i).map((i) => <SelectItem key={i} value={i+1} textValue={"P" + (i+1)}>P{i+1}</SelectItem>)
}
</Select>
<Button
isDisabled={accessKey.length === 0 || pid === -1}
onClick={() => {
resetModel();
resetStudyModel();
window.location.hash = '/study' + '?pid=' + (pid+1) + `&k=${btoa(accessKey)}` + '&studyType=READING';
}}
>Start</Button>
</div>
</CardBody>
<Divider />
<CardBody>
<span style={{fontWeight: 800}}>Run study 2</span>
<div style={{display: 'flex', flexDirection: 'row', justifyContent: 'left', alignItems: 'center', gap: 40, marginTop: 10}}>
<Select isDisabled={accessKey.length === 0}
variant="faded" label="Participant ID" className="max-w-xs"
onChange={(e) => setPid(parseInt(e.target.value))}>
{
Array.from({length: 12}, (_, i) => i).map((i) => <SelectItem key={i} value={i+1} textValue={"P" + (i+1)}>P{i+1}</SelectItem>)
}
</Select>
<Button
isDisabled={accessKey.length === 0 || pid === -1}
onClick={() => {
resetModel();
resetStudyModel();
window.location.hash = '/study' + '?pid=' + (pid+1) + `&k=${btoa(accessKey)}` + '&studyType=WRITING';
}}
>Start</Button>
</div>
</CardBody>
</Card>
</div>
}
+204
View File
@@ -0,0 +1,204 @@
import { Button } from '@nextui-org/react';
import React, { useCallback } from 'react';
import { FaCheck } from 'react-icons/fa6';
import { ImCross } from 'react-icons/im';
import { Editor, NodeEntry, Range, Transforms, createEditor } from 'slate';
import { Editable, ReactEditor, RenderLeafProps, Slate, withReact } from "slate-react";
import { useModelStore } from '../model/Model';
import { SlateUtils } from '../model/SlateUtils';
import { TextUtils } from '../model/TextUtils';
import { useViewModelStore } from '../model/ViewModel';
const Leaf = (props: any) => {
// By default we just render a basic span
const classes = [];
if (props.leaf.added) {
classes.push('suggest-addition');
} else if (props.leaf.removed) {
classes.push('suggest-deletion');
}
if (props.leaf.highlight) classes.push('highlight');
return <span className={classes.join(" ")} {...props.attributes}>{props.children}</span>
}
export const globalEditor = withReact(createEditor());
// @ts-ignore
window['globalEditor'] = globalEditor;
const { normalizeNode } = globalEditor
globalEditor.normalizeNode = entry => {
const [node, path] = entry
if (path.length === 0) { // Root node
const paragraphs = (node as any).children;
// Ensure that there is only one paragraph
if (paragraphs.length > 1) {
// Add a new line at the begining of the following paragraph
Transforms.insertText(globalEditor, "\n", { at: {path: [1, 0], offset: 0} })
//useOriginalRemoveNodes = true;
Transforms.mergeNodes(globalEditor, { at: [1] })
//useOriginalRemoveNodes = false;
}
}
// Fallback to the original `normalizeNode` to enforce other constraints.
normalizeNode(entry)
}
export default function TextEditor({overlayOnHover = true} : {overlayOnHover?: boolean}) {
const setTextState = useModelStore(state => state.setTextState);
const textIsBeingEdited = useViewModelStore(state => state.textIsBeingEdited);
const divRef = React.createRef<HTMLDivElement>();
const isTextSuggested = useModelStore(state => state.isTextSuggested)();
const isReadOnly = useModelStore(state => state.isReadOnly);
const textActionMatches = useModelStore(state => state.textActionMatches);
const filteredActionsSegment = useModelStore(state => state.filteredActionsSegment);
const highlightedActionsSegment = useModelStore(state => state.highlightedActionsSegment);
const selectedEdges = useModelStore(state => state.selectedEdges);
const actionEdges = useModelStore(state => state.actionEdges);
const selectedNodes = useModelStore(state => state.selectedNodes);
const highlightedEntities = useModelStore(state => state.highlightedEntities);
const renderLeaf = useCallback((props: RenderLeafProps) => {
return <Leaf {...props} editor={globalEditor} />
}, []);
const activeSelectionDecoration = useCallback(
([node, path]: NodeEntry) => {
const ranges : Range[] = [];
let idsToDecorate : number[] = [];
if (selectedEdges.length > 0) {
for (const edge of selectedEdges) {
const index = actionEdges.findIndex(e => e.id === edge);
if (index >= 0) {
idsToDecorate.push(index);
}
}
}
const filter = highlightedActionsSegment || filteredActionsSegment;
if (filter) {
for (let i = filter.start; i <= filter.end; i++) {
if (idsToDecorate.indexOf(i) === -1) {
idsToDecorate.push(i);
}
}
}
if (selectedNodes.length > 0 || highlightedEntities.length > 0) {
const entitiesToConsider = highlightedEntities.concat(selectedNodes);
const edgesToConsider = useModelStore.getState().getFilteredActionEdges(filter);
const edgesConnectedToEntities = edgesToConsider.filter(edge => entitiesToConsider.includes(edge.source) || entitiesToConsider.includes(edge.target));
idsToDecorate = []; // This takes priority over other filters
for (const edge of edgesConnectedToEntities) {
const index = actionEdges.findIndex(e => e.id === edge.id);
if (index >= 0) {
idsToDecorate.push(index);
}
}
}
if (idsToDecorate.length > 0) {
for (const i of idsToDecorate) {
const start = textActionMatches[i].start;
const end = textActionMatches[i].end;
const startPt = SlateUtils.toSlatePoint(useModelStore.getState().textState, start);
const endPt = SlateUtils.toSlatePoint(useModelStore.getState().textState, end);
if (startPt && endPt) {
const range = { anchor: startPt, focus: endPt };
const intersection = Range.intersection(range, Editor.range(globalEditor, path));
if (intersection) {
ranges.push({
anchor: intersection.anchor,
focus: intersection.focus,
highlight: true,
} as any);
}
}
}
}
return ranges;
},
[textActionMatches, filteredActionsSegment, highlightedActionsSegment, selectedEdges, selectedNodes, highlightedEntities]
)
return (
<>
<div ref={divRef} onClick={(e) => {
if (e.target === divRef.current) {
useModelStore.getState().setFilteredActionsSegment(null, null);
useModelStore.getState().setHighlightedActionsSegment(null, null);
}
}} className={textIsBeingEdited ? "loading" : ""} style={{ position: 'relative', background: 'white', height: '100%', width: '50%', paddingTop: 60, paddingLeft: 50, paddingRight: 50, borderRadius: '2px', boxShadow: '0 0 10px rgba(0,0,0,0.1)', overflow: 'scroll' }}>
<Slate onSelectionChange={(selection) => {
if (!isReadOnly && selection) {
const startPoint = selection?.anchor;
const endPoint = selection?.focus;
const startIndex = SlateUtils.toStrIndex(globalEditor.children, startPoint);
const endIndex = SlateUtils.toStrIndex(globalEditor.children, endPoint);
const actions = TextUtils.getActionsAtPosition(useModelStore.getState().textActionMatches, Math.min(startIndex, endIndex), Math.max(startIndex, endIndex), true);
if (actions.length > 0) {
useModelStore.getState().setFilteredActionsSegment(actions[0].index, actions[actions.length - 1].index);
} else {
useModelStore.getState().setFilteredActionsSegment(null, null);
}
}
}}
editor={globalEditor} initialValue={useModelStore.getState().textState} onChange={newValue => {
setTextState(newValue, false);
}}>
<Editable
readOnly={isReadOnly}
decorate={activeSelectionDecoration}
onMouseLeave={() => {
useModelStore.getState().setHighlightedActionsSegment(null, null);
}}
onMouseMove={(e) => {
if (!overlayOnHover) return;
// Get the index of the character under the mouse
const pos = TextUtils.caretPositionFromPoint(e.clientX, e.clientY);
if (pos) {
const slatePoint = ReactEditor.toSlatePoint(globalEditor, [pos.offsetNode, pos.offset], { exactMatch: true, suppressThrow: true });
if (slatePoint) {
const index = SlateUtils.toStrIndex(globalEditor.children, slatePoint);
const actions = TextUtils.getActionsAtPosition(useModelStore.getState().textActionMatches, index, index, true);
if (actions.length > 0) {
useModelStore.getState().setHighlightedActionsSegment(actions[0].index, actions[actions.length - 1].index);
} else {
useModelStore.getState().setHighlightedActionsSegment(null, null);
}
}
}
}} renderLeaf={renderLeaf} />
</Slate>
{isTextSuggested && <div style={{ position: 'absolute', top: 10, transform: 'translate(-50%, 0)', left: '50%' }}>
<Button size="sm" variant='faded' style={{marginRight: 5}} onClick={() => useModelStore.getState().acceptSuggestions()}><FaCheck /> Accept changes</Button>
<Button size="sm" variant='faded' onClick={() => useModelStore.getState().rejectSuggestions()} ><ImCross /> Reject changes</Button>
</div>}
</div>
</>
)
}
+171
View File
@@ -0,0 +1,171 @@
import { Button, Tab, Tabs, Tooltip } from '@nextui-org/react';
import { ReactFlowProvider, useKeyPress } from '@xyflow/react';
import React, { useEffect, useState } from 'react';
import { FaTrashAlt } from 'react-icons/fa';
import { FaLocationDot } from 'react-icons/fa6';
import { IoPersonCircle } from 'react-icons/io5';
import { TbArrowBigLeftLinesFilled, TbArrowBigRightLinesFilled } from 'react-icons/tb';
import { useHistoryModelStore } from '../model/HistoryModel';
import { LayoutUtils } from '../model/LayoutUtils';
import { useModelStore } from '../model/Model';
import { RewriteFromVisual } from '../model/prompts/textEditors/RewriteFromVisual';
import { EntitiesExtractor } from '../model/prompts/textExtractors/EntitiesExtractor';
import { LocationExtractor } from '../model/prompts/textExtractors/LocationsExtractor';
import { VisualRefresher } from '../model/prompts/textExtractors/VisualRefresher';
import { useStudyStore } from '../study/StudyModel';
import HistoryTree from './HistoryTree';
import TextEditor from './TextEditor';
import ActionTimeline from './actionTimeline/ActionTimeline';
import EntitiesEditor from './entityActionView/EntitiesEditor';
import LocationsEditor from './locationView/LocationsEditor';
export default function VisualWritingInterface(props: { children?: React.ReactNode }) {
const [isExtracting, setIsExtracting] = useState(false);
const [selectedTab, setSelectedTab] = useState('entities');
const isStale = useModelStore(state => state.isStale);
const isReadOnly = useModelStore(state => state.isReadOnly);
const escapePressed = useKeyPress(["Escape"]);
const visualPanelRef = React.createRef<HTMLDivElement>();
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
// undo/redo
if ((e.metaKey || e.ctrlKey) && e.key === 'z') {
e.preventDefault();
if (e.shiftKey) {
useHistoryModelStore.getState().redo();
} else {
useHistoryModelStore.getState().undo();
}
}
}
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('keydown', onKeyDown);
}
}, []);
useEffect(() => {
if (escapePressed) {
// Unselect everything that can be selected
useModelStore.getState().setSelectedNodes([]);
useModelStore.getState().setSelectedEdges([]);
useModelStore.getState().setFilteredActionsSegment(null, null);
}
}, [escapePressed]);
useEffect(() => {
const center = { x: visualPanelRef.current!.clientWidth / 2, y: visualPanelRef.current!.clientHeight / 2 };
LayoutUtils.optimizeNodeLayout("entity", useModelStore.getState().entityNodes, useModelStore.getState().setEntityNodes, center, 120, 100);
LayoutUtils.optimizeNodeLayout("location", useModelStore.getState().locationNodes, useModelStore.getState().setLocationNodes, center, 120);
}, [selectedTab]);
useEffect(() => {
const center = { x: visualPanelRef.current!.clientWidth / 2, y: visualPanelRef.current!.clientHeight / 2 };
// Make sure we update the layout everytime there is a refresh
VisualRefresher.getInstance().onUpdate = () => {
LayoutUtils.optimizeNodeLayout("locations", useModelStore.getState().locationNodes, useModelStore.getState().setLocationNodes, { x: center.x, y: center.y }, 120);
LayoutUtils.optimizeNodeLayout("entity", useModelStore.getState().entityNodes, useModelStore.getState().setEntityNodes, { x: center.x, y: center.y }, 120, 100);
}
VisualRefresher.getInstance().onRefreshDone = () => {
// Not stale anymore
if (useModelStore.getState().isStale) {
useModelStore.getState().setIsStale(false);
}
}
});
const setSelectedTabLogged = (tab: string) => {
useStudyStore.getState().logEvent("TAB_CHANGE", { tab });
setSelectedTab(tab);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
<div style={{ display: 'flex', flexDirection: 'row', flexGrow: 1, height: '80%' }}>
{props.children}
<TextEditor />
<div className='flex flex-col' style={{ position: 'relative' }}>
<div style={{ width: '50vw', height: '100%', background: '#F3F4F6', borderLeft: '1px solid #DDDDDF', borderBottom: '1px solid #DDDDDF' }} ref={visualPanelRef}>
{selectedTab === "entities" && <ReactFlowProvider><EntitiesEditor /></ReactFlowProvider>}
{selectedTab === "locations" && <ReactFlowProvider><LocationsEditor /></ReactFlowProvider>}
<Tabs keyboardActivation='manual' onSelectionChange={setSelectedTabLogged as any} selectedKey={selectedTab} color='primary' variant='bordered' style={{ position: 'absolute', left: '50%', top: 10, transform: 'translate(-50%, 0)' }} classNames={{ tabList: 'bg-white', }}>
<Tab key={"entities"} title={<span style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', fontSize: 15 }}><IoPersonCircle style={{ marginRight: 3, fontSize: 22 }} /> Entities & Actions</span>} />
<Tab key={'locations'} title={<span style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', fontSize: 15 }}><FaLocationDot style={{ marginRight: 3, fontSize: 18 }} /> Locations</span>} />
</Tabs>
{!isReadOnly && <Button style={{ position: 'absolute', right: 10, top: 10, fontSize: 18 }} isIconOnly onClick={(e) => {
console.log(useModelStore.getState().entityNodes);
// Cancel exisitng animations because otherwise they might revive the deleted nodes
LayoutUtils.stopAllSimulations();
useModelStore.getState().setActionEdges([]);
useModelStore.getState().setLocationNodes([]);
useModelStore.getState().setEntityNodes([]);
useModelStore.getState().setFilteredActionsSegment(null, null);
useModelStore.getState().setHighlightedActionsSegment(null, null);
VisualRefresher.getInstance().reset();
}}><FaTrashAlt /></Button>}
</div>
<ReactFlowProvider><ActionTimeline /></ReactFlowProvider>
{!isReadOnly && <div style={{ display: 'flex', flexDirection: 'column', gap: 5, position: 'absolute', left: 0, top: '50%', transform: 'translate(-50%, -50%)', fontSize: 22 }}>
<Tooltip content="Refresh from text" closeDelay={0}>
<Button style={{ fontSize: 22 }} color={isStale ? "primary": "default"} isLoading={isExtracting} isIconOnly radius={'full'}
onClick={() => {
const center = { x: visualPanelRef.current!.clientWidth / 2, y: visualPanelRef.current!.clientHeight / 2 };
const visualRefreshCallback = () => {
VisualRefresher.getInstance().refreshFromText(useModelStore.getState().text,
() => { },
() => {
setIsExtracting(false);
});
}
setIsExtracting(true);
const entitiesExtractor = EntitiesExtractor(useModelStore.getState().text, center);
const locationsExtractor = LocationExtractor(useModelStore.getState().text, center);
let refreshRequirements: Promise<any> = new Promise<void>((resolve, reject) => { resolve() });
if (useModelStore.getState().locationNodes.length === 0 && useModelStore.getState().entityNodes.length === 0) refreshRequirements = Promise.all([entitiesExtractor, locationsExtractor]);
if (useModelStore.getState().locationNodes.length === 0) refreshRequirements = locationsExtractor;
if (useModelStore.getState().entityNodes.length === 0) refreshRequirements = entitiesExtractor;
refreshRequirements.then((response) => {
visualRefreshCallback();
});
}}
>
<TbArrowBigRightLinesFilled />
</Button>
</Tooltip>
<Tooltip placement='bottom' content="Write from visual" closeDelay={0}>
<Button style={{ fontSize: 22 }} isLoading={isExtracting} isIconOnly radius={'full'}
onClick={() => {
new RewriteFromVisual().execute();
}}
>
<TbArrowBigLeftLinesFilled />
</Button>
</Tooltip>
</div>}
</div>
</div>
{!isReadOnly && <HistoryTree />}
</div>
)
}
+343
View File
@@ -0,0 +1,343 @@
import { ConnectionMode, Node, NodeProps, ReactFlow, ViewportPortal, applyNodeChanges, useReactFlow } from '@xyflow/react';
import { useEffect, useMemo, useState } from 'react';
import { Button, Slider } from '@nextui-org/react';
import '@xyflow/react/dist/style.css';
import { GrNext, GrPrevious } from 'react-icons/gr';
import { ActionEdge, EntityNode, useModelStore } from '../../model/Model';
import { ReorderActionPrompt } from '../../model/prompts/textEditors/ReorderActionPrompt';
import { useStudyStore } from '../../study/StudyModel';
type Timeline = Node<{
width: number;
height: number;
entity: EntityNode;
}>;
type Link = Node<{
width: number;
height: number;
action: ActionEdge;
}>
// Bunch of constants for easy customization
const paddingLeft = 150;
const paddingTop = 15
const timelineHeight = 1;
const timelineSpacing = 30;
const actionSpacing = 30;
const actionWidth = 5;
const actionLeftPadding = actionSpacing / 2;
function TimelineNode(props: NodeProps<Timeline>) {
return <>
<div style={{ width: props.data.width, height: props.data.height, background: '#dddddd', borderRadius: 3, transform: 'translate(0%, -50%)' }}>
<span style={{ transform: 'translate(-100%, -50%)', position: 'absolute', left: -10 }}>{props.data.entity.data.emoji} {props.data.entity.data.name}</span>
</div>
</>
}
function ActionLinkNode(props: NodeProps<Link>) {
const barHeight = 25;
const entities = useModelStore.getState().entityNodes;
const sourceIndex = entities.findIndex(node => node.id === props.data.action.source);
const targetIndex = entities.findIndex(node => node.id === props.data.action.target);
const topIndex = Math.min(sourceIndex, targetIndex);
const bottomIndex = Math.max(sourceIndex, targetIndex);
if (topIndex === -1 || bottomIndex === -1 || sourceIndex >= entities.length || targetIndex >= entities.length) {
return <></>
}
const topEntity = entities[topIndex];
const bottomEntity = entities[bottomIndex];
const isInverted = targetIndex === topIndex;
const getMarker = (emoji: string, size: number) => {
return <div style={{ width: size, height: size, background: 'rgba(255, 255, 255, 0)', borderRadius: 9999, display: 'flex', flexDirection: 'row', alignItems: 'center', justifyContent: 'center' }}>
{emoji}
</div>
}
if (topIndex === bottomIndex) {
// Action is within the same entity
return <div style={{ position: 'relative', display: 'flex', flexDirection: 'row', alignItems: 'center', width: barHeight, height: barHeight, transform: `translate(-100%, -50%)`, top: 0, left: '50%', background: '#BEBEDE', border: `1px solid rgb(73, 75, 168)`, borderRadius: 999 }}>
{getMarker(topEntity.data.emoji, barHeight)}
</div>
}
return <>
<div style={{ position: 'relative', width: props.data.width, height: props.data.height, background: 'rgb(73, 75, 168)', transform: 'translate(-50%, 0%)', opacity: props.dragging ? 0.5 : 1 }}>
<div style={{ position: 'absolute', display: 'flex', flexDirection: 'row', alignItems: 'center', justifyContent: isInverted ? 'right' : 'left', width: barHeight, height: barHeight, transform: `translate(-50%, -50%)`, top: 0, left: '50%', background: '#BEBEDE', border: `1px solid rgb(73, 75, 168)`, borderRadius: 999 }}>
{getMarker(topEntity.data.emoji, barHeight)}
</div>
<div style={{ position: 'absolute', display: 'flex', flexDirection: 'row', alignItems: 'center', justifyContent: isInverted ? 'left' : 'right', width: barHeight, height: barHeight, transform: `translate(-50%, 50%)`, bottom: 0, left: '50%', background: '#BEBEDE', border: `1px solid rgb(73, 75, 168)`, borderRadius: 999 }}>
{getMarker(bottomEntity.data.emoji, barHeight)}
</div>
</div>
</>
}
export default function ActionTimeline() {
const entityNodes = useModelStore(state => state.entityNodes);
const actionEdges = useModelStore(state => state.actionEdges);
const highlightedActionsSegment = useModelStore(state => state.highlightedActionsSegment);
const setHighlightedActionsSegment = useModelStore(state => state.setHighlightedActionsSegment);
const filteredActionsSegment = useModelStore(state => state.filteredActionsSegment);
const setFilteredActionsSegment = useModelStore(state => state.setFilteredActionsSegment);
const [isSelecting, setIsSelecting] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const isReadOnly = useModelStore(state => state.isReadOnly);
const [entityTimelines, setEntityTimelines] = useState<Timeline[]>([]);
const [actionLinks, setActionLinks] = useState<Link[]>([]);
const nodeTypes = useMemo(() => ({
timelineNode: TimelineNode,
actionLinkNode: ActionLinkNode
}), []);
const maxLength = Math.max(actionSpacing, actionEdges.length * actionSpacing);
const maxHeight = entityNodes.length * (timelineHeight + timelineSpacing);
const boundaries: [[number, number], [number, number]] = [
[0, 0],
[maxLength + paddingLeft + 10, (entityNodes.length) * (timelineHeight + timelineSpacing) + 10]
]
useEffect(() => {
const newEntityTimelines: Timeline[] = entityNodes.map((entityNode, index) => {
return {
id: entityNode.id,
type: "timelineNode",
draggable: false,
selectable: false,
position: { x: paddingLeft, y: index * (timelineHeight + timelineSpacing) + paddingTop },
data: { width: maxLength, height: timelineHeight, entity: entityNode }
}
});
const newActionLinks: Link[] = actionEdges.map((actionEdge, index) => {
const sourceIndex = entityNodes.findIndex(node => node.id === actionEdge.source);
const targetIndex = entityNodes.findIndex(node => node.id === actionEdge.target);
return {
id: "action" + actionEdge.id,
type: "actionLinkNode",
draggable: !isReadOnly,
selectable: !isReadOnly,
position: { x: index * actionSpacing + paddingLeft + actionLeftPadding, y: Math.min(sourceIndex, targetIndex) * (timelineHeight + timelineSpacing) + paddingTop },
data: { width: actionWidth, height: Math.abs(sourceIndex - targetIndex) * (timelineHeight + timelineSpacing), action: actionEdge }
}
});
setEntityTimelines(newEntityTimelines);
setActionLinks(newActionLinks);
}, [entityNodes, actionEdges]);
const reactFlow = useReactFlow();
const [zoom, setZoom] = useState(reactFlow.getZoom());
// Listen for key pressed left or right to scroll the timeline
useEffect(() => {
const listener = (e : KeyboardEvent) => {
if (isHovered && e.key === 'ArrowLeft') {
reactFlow.setViewport({ ...reactFlow.getViewport(), x: reactFlow.getViewport().x + 100 }, { duration: 100 })
e.preventDefault();
} else if (isHovered && e.key === 'ArrowRight') {
reactFlow.setViewport({ ...reactFlow.getViewport(), x: reactFlow.getViewport().x - 100 }, { duration: 100 })
e.preventDefault();
}
};
document.addEventListener('keydown', listener);
return () => {
document.removeEventListener('keydown', listener);
}
}, [reactFlow, isHovered]);
let nbEventsShown = highlightedActionsSegment ? highlightedActionsSegment.end - highlightedActionsSegment.start + 1 : filteredActionsSegment ? filteredActionsSegment.end - filteredActionsSegment.start + 1: actionEdges.length;
const highlightedEntities = useModelStore(state => state.highlightedEntities);
if (highlightedEntities.length > 0) {
nbEventsShown = actionEdges.filter(edge => highlightedEntities.includes(edge.source) || highlightedEntities.includes(edge.target)).length;
}
return (
<>
<div
style={{ width: '100%', height: 40, background: "rgb(247 246 249)", border: 'solid 1px rgb(242 242 244)', display: 'flex', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Slider size='sm' value={zoom}
style={{width: 100, marginLeft: 10}}
aria-label='Zoom slider'
showSteps={true} minValue={0.5} maxValue={1.5} step={0.1}
onChange={(value) => {
// Change the zoom level
reactFlow.zoomTo(value as number, { duration: 50 });
setZoom(reactFlow.getZoom());
}}>
</Slider>
<span style={{fontSize: 12, color: 'rgba(0,0,0,0.5)', marginRight: 10}}>
{nbEventsShown < actionEdges.length ? `Viewing ${nbEventsShown} / ${actionEdges.length} Events` : `Viewing all ${actionEdges.length} Events`}
</span>
<div>
<Button variant='light' size="sm" isIconOnly onClick={() => {
// Scroll the react flow to the left
reactFlow.setViewport({ ...reactFlow.getViewport(), x: reactFlow.getViewport().x + 100 }, { duration: 100 })
}}>
<GrPrevious />
</Button>
<Button variant='light' size="sm" isIconOnly onClick={() => {
// Scroll the react flow to the right
reactFlow.setViewport({ ...reactFlow.getViewport(), x: reactFlow.getViewport().x - 100 }, { duration: 100 })
}}>
<GrNext />
</Button>
</div>
</div>
<div style={{ width: '100%', height: 300 }}>
<ReactFlow
onMouseMove={(e) => {
const pos = reactFlow.screenToFlowPosition({ x: e.clientX, y: e.clientY }, { snapToGrid: false })
// Find the closest action link
const index = Math.round((pos.x - paddingLeft - actionLeftPadding) / actionSpacing);
const isValid = index >= 0 && index < actionEdges.length;
if (isSelecting) {
const min = Math.min(filteredActionsSegment!.start, filteredActionsSegment!.end, index);
const max = Math.max(filteredActionsSegment!.start, filteredActionsSegment!.end, index);
setFilteredActionsSegment(min, max);
} else {
if (isValid) {
setHighlightedActionsSegment(index, index);
} else {
setHighlightedActionsSegment(null, null);
}
}
}}
onMouseLeave={(e) => {
setHighlightedActionsSegment(null, null);
setIsHovered(false);
}}
onMouseEnter={(e) => {
setIsHovered(true);
}}
onMouseDown={(e) => {
if (e.button === 0) {
const pos = reactFlow.screenToFlowPosition({ x: e.clientX, y: e.clientY }, { snapToGrid: false })
const index = Math.round((pos.x - paddingLeft - actionLeftPadding) / actionSpacing);
if (index >= 0 && index < actionEdges.length) {
setFilteredActionsSegment(index, index);
setHighlightedActionsSegment(null, null);
setIsSelecting(true);
} else {
setFilteredActionsSegment(null, null);
setHighlightedActionsSegment(null, null);
setIsSelecting(false);
}
}
e.preventDefault();
e.stopPropagation();
}}
onMouseUp={(e) => {
if (e.button === 0) {
setIsSelecting(false);
useStudyStore.getState().logEvent("TIMELINE_SELECTED", { start: filteredActionsSegment?.start, end: filteredActionsSegment?.end });
}
}}
panOnDrag={false}
translateExtent={boundaries}
zoomOnDoubleClick={false}
defaultViewport={{ x: 0, y: boundaries[0][1], zoom: 1 }}
nodes={[...entityTimelines, ...actionLinks]}
connectionMode={ConnectionMode.Loose}
nodeTypes={nodeTypes as any}
deleteKeyCode={[]}
fitView={false}
panOnScroll={true}
onNodesChange={(changes) => {
changes.map(change => {
if (change.type === 'position' && change.position) {
const actionLink = actionLinks.find(node => node.id === change.id);
if (actionLink) {
change.position.y = actionLink.position.y;
change.position.x = Math.max(paddingLeft, Math.min(paddingLeft + maxLength, change.position.x));
}
}
})
// @ts-ignore
setActionLinks(applyNodeChanges(changes, actionLinks))
}}
onNodeDragStart={(event, node) => {
setHighlightedActionsSegment(null, null);
setFilteredActionsSegment(null, null);
}}
onNodeDragStop={(event, node) => {
const targetIndex = Math.round((node.position.x - paddingLeft - actionLeftPadding+actionSpacing/2) / actionSpacing);
const originalIndex = actionLinks.findIndex(link => link.id === node.id);
if (node.type === "actionLinkNode" && targetIndex >= 0 && targetIndex < actionEdges.length+1 && targetIndex !== originalIndex) {
const actionLinkNode = node as Link;
new ReorderActionPrompt(actionLinkNode.data?.action?.data as any, originalIndex, targetIndex).execute()
}
}}
>
<ViewportPortal>
{highlightedActionsSegment &&
<div style={{
pointerEvents: 'none',
position: 'absolute', left: -(actionSpacing) / 2, height: maxHeight, width: (actionSpacing), background: 'rgba(0,0,0,0.2)',
transform: `translate(${highlightedActionsSegment.start * actionSpacing + paddingLeft + actionLeftPadding}px, 0px)`
}} />
}
{filteredActionsSegment &&
<div style={{
borderLeft: '2px solid rgba(100,100,100,0.5)',
borderRight: '2px solid rgba(100,100,100,0.5)',
pointerEvents: 'none',
position: 'absolute', left: -(actionSpacing) / 2, height: maxHeight, width: (actionSpacing) * ((filteredActionsSegment.end - filteredActionsSegment.start)+1), background: 'rgba(0,0,0,0.2)',
transform: `translate(${filteredActionsSegment.start * actionSpacing + paddingLeft + actionLeftPadding}px, 0px)`
}} />
}
</ViewportPortal>
</ReactFlow>
</div>
</>
)
}
@@ -0,0 +1,232 @@
import { BaseEdge, EdgeLabelRenderer, EdgeProps, getBezierPath, useInternalNode, useKeyPress, useReactFlow } from '@xyflow/react';
import React, { useEffect, useState } from 'react';
import '@xyflow/react/dist/style.css';
import { GrFormNext, GrFormPrevious } from 'react-icons/gr';
import { ActionEdge, useModelStore } from '../../model/Model';
import { ChangeActionPrompt } from '../../model/prompts/textEditors/ChangeActionPrompt';
import { RemoveActionPrompt } from '../../model/prompts/textEditors/RemoveActionPrompt';
import { getEdgeParams } from '../utils/initialElements';
export default function ActionEdgeComponent(props: EdgeProps<ActionEdge>) {
const sourceNode = useInternalNode(props.source);
const targetNode = useInternalNode(props.target);
const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams(
sourceNode,
targetNode,
);
let [edgePath, labelX, labelY] = getBezierPath({
sourceX: sx,
sourceY: sy,
sourcePosition: sourcePos,
targetPosition: targetPos,
targetX: tx,
targetY: ty,
});
const [isBeingEdited, setIsBeingEdited] = useState(false);
const [temproraryName, setTemporaryName] = useState(props.data!.name);
const [shouldSelectText, setShouldSelectText] = useState(false);
const inputFieldRef = React.createRef<HTMLInputElement>();
const { setEdges } = useReactFlow();
const deletePressed = useKeyPress(["Delete", "Backspace"]);
const selectedEdges = useModelStore(state => state.selectedEdges);
const isSelected = selectedEdges.includes(props.id);
// Test if there are other actions that have the same source and target (i.e., there is an overlap)
const getFilteredActionEdges = useModelStore(state => state.getFilteredActionEdges);
const filteredActionsSegment = useModelStore(state => state.filteredActionsSegment);
const highlightedActionsSegment = useModelStore(state => state.highlightedActionsSegment);
const filteredEdges = getFilteredActionEdges(highlightedActionsSegment ? highlightedActionsSegment : filteredActionsSegment);
const overlappingEdges = filteredEdges.filter(edge => edge.source === props.source && edge.target === props.target);
const inverseEdges = filteredEdges.filter(edge => edge.source === props.target && edge.target === props.source);
const isGoingBackwards = props.sourceX > props.targetX;
const currentEdgeIndex = overlappingEdges.findIndex(edge => edge.id === props.id);
const isLastOverlappingEdge = currentEdgeIndex === overlappingEdges.length - 1;
const idxOfSelectedOverlappedEdge = overlappingEdges.findIndex(edge => selectedEdges.includes(edge.id));
const [selectedOverlappingEdgeIdx, setSelectedOverlappingEdgeIdx] = useState(idxOfSelectedOverlappedEdge != -1 ? idxOfSelectedOverlappedEdge : currentEdgeIndex);
const highlightedEntities = useModelStore(state => state.highlightedEntities);
const selectedNodes = useModelStore(state => state.selectedNodes);
const isAnimated = highlightedEntities.length > 0 && (highlightedEntities.includes(props.source) || highlightedEntities.includes(props.target));
const isFaded = (highlightedEntities.length > 0 && !isAnimated) || (selectedNodes.length > 0 && !selectedNodes.includes(props.source) && !selectedNodes.includes(props.target));
const isReadOnly = useModelStore(state => state.isReadOnly);
useEffect(() => {
if (deletePressed && useModelStore.getState().selectedEdges.includes(props.id)) {
// Remove the edge
setEdges((edges) => edges.filter((edge) => edge.id !== props.id));
// Modify the story accordingly by executing a prompt
const sourceNode = useModelStore.getState().entityNodes.find(node => node.id === props.source)
const targetNode = useModelStore.getState().entityNodes.find(node => node.id === props.target)
if (sourceNode && targetNode) {
new RemoveActionPrompt(sourceNode.data, targetNode.data, props.data!).execute()
}
}
}, [deletePressed])
if (props.source === props.target) {
// This is a self-loop, need to render the path differently
const radiusX = (props.sourceX - props.targetX) * 0.6;
const radiusY = 50;
edgePath = `M ${props.sourceX - 5} ${props.sourceY} A ${radiusX} ${radiusY} 0 1 0 ${props.targetX + 2
} ${props.targetY}`;
// Calculate the label position so that it is on edge path
labelX = (props.sourceX + props.targetX) / 2
labelY = props.sourceY + (props.sourceY > props.targetY ? -radiusY * 1.5 : radiusY * 1.5);
}
const onNameInputValidated = () => {
setIsBeingEdited(false);
if (temproraryName !== overlappingEdges[selectedOverlappingEdgeIdx].data!.name) {
setEdges((edges) => {
const edgeToModify = edges.find(edge => edge.id === overlappingEdges[selectedOverlappingEdgeIdx].id);
if (edgeToModify) {
const previousAction = { ...edgeToModify.data! };
edgeToModify.data!.name = temproraryName;
// Modify the story accordingly by executing a prompt
const sourceNode = useModelStore.getState().entityNodes.find(node => node.id === edgeToModify.source)
const targetNode = useModelStore.getState().entityNodes.find(node => node.id === edgeToModify.target)
if (sourceNode && targetNode) {
new ChangeActionPrompt(sourceNode.data, targetNode.data, previousAction! as any, edgeToModify.data! as any).execute()
}
}
return [...edges];
});
}
}
useEffect(() => {
if (shouldSelectText) {
inputFieldRef.current?.select();
setShouldSelectText(false);
}
}, [shouldSelectText]);
let labelPositionTransform = `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`
// Avoid overlapping labels when there are edges going in the opposite direction
if (inverseEdges.length > 0) {
if (isGoingBackwards) {
labelPositionTransform = `translate(-50%, -105%) translate(${labelX}px,${labelY}px)`
} else {
labelPositionTransform = `translate(-50%, 5%) translate(${labelX}px,${labelY}px)`
}
}
return (
<>
<BaseEdge path={edgePath} markerEnd={props.markerEnd}
style={{ ...props.style, stroke: isSelected ? '#326FEE' : '#888', strokeWidth: isSelected ? 2 : 1, cursor: 'pointer' }} />
{isAnimated && isLastOverlappingEdge && overlappingEdges.map((edge, idx) => {
const offset = (idx / Math.min(overlappingEdges.length, 15))+0.5; // 5 maximum
const isSending = highlightedEntities.includes(edge.source);
const isReceiving = highlightedEntities.includes(edge.target);
let fillColour = isSending ? "#326FEE" : isReceiving ? "#EEB132" : "#888";
fillColour = isSending && isReceiving ? "url(#grad1)" : fillColour;
return (<g key={edge.data!.name}>
<defs>
<filter x="0" y="0" width="1" height="1" id="solid">
<feFlood floodColor="white" result="bg" />
<feMerge>
<feMergeNode in="bg"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style={{stopColor: "#326FEE", stopOpacity: 1}} />
<stop offset="49%" style={{stopColor: "#326FEE", stopOpacity: 1}} />
<stop offset="50%" style={{stopColor: "#EEB132", stopOpacity: 1}} />
<stop offset="100%" style={{stopColor: "#EEB132", stopOpacity: 1}} />
</linearGradient>
</defs>
<circle cx={0} cy={0} r={4} fill={fillColour} />
<text filter="url(#solid)" textAnchor={isGoingBackwards ? 'end' : 'start'} dominantBaseline={isGoingBackwards ? 'auto' : 'hanging'} style={{fontSize: 10, fill: '#000', transform: isGoingBackwards ? "translate(-5px,-5px)" : "translate(5px,5px)"}}>
{edge.data!.name}
</text>
<animateMotion begin={offset*-10} dur="10s" repeatCount="indefinite" path={edgePath} />
</g>)
})}
{!isAnimated && <EdgeLabelRenderer>
<div
style={{
position: 'absolute',
display: isLastOverlappingEdge || isSelected ? 'flex' : 'none',
flexDirection: 'row',
alignItems: 'center',
transform: labelPositionTransform,
fontSize: 12,
// everything inside EdgeLabelRenderer has no pointer events by default
// if you have an interactive element, set pointer-events: all
pointerEvents: 'all',
boxShadow: 'rgba(0, 0, 0, 0.24) 0px 3px 8px',
zIndex: isSelected ? 9999 : 0,
background: isSelected ? 'rgb(73 129 244)' : '#eee',
color: isSelected ? 'white' : 'black',
opacity: isFaded ? 0.3 : 1,
}}
className="nodrag nopan"
>
{overlappingEdges.length > 1 && <button onClick={(e) => {
e.stopPropagation();
const idx = (selectedOverlappingEdgeIdx - 1 + overlappingEdges.length) % overlappingEdges.length;
setSelectedOverlappingEdgeIdx(idx);
if (isSelected) useModelStore.getState().setSelectedEdges([overlappingEdges[idx].id]);
}}>
<GrFormPrevious />
</button>}
{!isBeingEdited && <button style={{ width: 100, overflow: 'clip', color: isSelected ? 'white' : 'black', borderRadius: 4, cursor: isReadOnly ? 'pointer' : 'text', background: isSelected ? '#326FEE' : 'white', borderLeft: '1px solid #c8c8c8', borderRight: '1px solid #c8c8c8', paddingLeft: 4, paddingRight: 4, whiteSpace: 'nowrap' }} onClick={(e) => {
if (useModelStore.getState().isReadOnly) return;
e.stopPropagation();
setTemporaryName(overlappingEdges[selectedOverlappingEdgeIdx].data!.name);
setIsBeingEdited(true);
setShouldSelectText(true);
}}>
{overlappingEdges.length <= selectedOverlappingEdgeIdx ? props.data!.name : overlappingEdges[selectedOverlappingEdgeIdx].data!.name}
</button>}
{isBeingEdited && <input ref={inputFieldRef} autoFocus style={{ background: '#white', borderRadius: 4 }}
value={temproraryName}
onChange={(e) => { setTemporaryName(e.target.value); }}
onBlur={() => { onNameInputValidated() }}
onKeyDown={(e) => { if (e.key === 'Enter') onNameInputValidated(); }}
/>}
{overlappingEdges.length > 1 &&
<div style={{marginLeft: 5}}>
({selectedOverlappingEdgeIdx+1}/{overlappingEdges.length})
</div>}
{overlappingEdges.length > 1 && <button onClick={(e) => {
e.stopPropagation();
const idx = (selectedOverlappingEdgeIdx + 1) % overlappingEdges.length;
setSelectedOverlappingEdgeIdx(idx);
if (isSelected) useModelStore.getState().setSelectedEdges([overlappingEdges[idx].id]);
}}>
<GrFormNext />
</button>}
</div>
</EdgeLabelRenderer>}
</>
);
}
@@ -0,0 +1,163 @@
import { Background, BackgroundVariant, ConnectionMode, Controls, ReactFlow, addEdge, applyEdgeChanges, applyNodeChanges, useReactFlow } from '@xyflow/react';
import { useEffect, useMemo, useRef, useState } from 'react';
import '@xyflow/react/dist/style.css';
import { LayoutUtils } from '../../model/LayoutUtils';
import { ActionEdge, useModelStore } from '../../model/Model';
import { AddActionPrompt } from '../../model/prompts/textEditors/AddActionPrompt';
import ActionEdgeComponent from './ActionEdgeComponent';
import EntityNodeComponent, { CreateEntityNode } from './EntityNodeComponent';
export default function EntitiesEditor() {
const entityNodes = useModelStore(state => state.entityNodes);
const actionEdges = useModelStore(state => state.actionEdges);
const getFilteredActionEdges = useModelStore(state => state.getFilteredActionEdges);
const setEntityNodes = useModelStore(state => state.setEntityNodes);
const setActionEdges = useModelStore(state => state.setActionEdges);
const setSelectedNodes = useModelStore(state => state.setSelectedNodes);
const setSelectedEdges = useModelStore(state => state.setSelectedEdges)
const filteredActionsSegment = useModelStore(state => state.filteredActionsSegment);
const highlightedActionsSegment = useModelStore(state => state.highlightedActionsSegment);
const [inputField, setInputField] = useState<{x: number, y: number, placeholder: string, onValidate: (text: string) => void} | null>(null);
const [currentMousePosition, setCurrentMousePosition] = useState<{x: number, y: number}>({x: 0, y: 0});
const inputFieldRef = useRef<HTMLInputElement>(null);
const divRef = useRef<HTMLDivElement>(null);
const highlightedEntities = useModelStore(state => state.highlightedEntities);
const selectedNodes = useModelStore(state => state.selectedNodes);
const isReadOnly = useModelStore(state => state.isReadOnly);
const { screenToFlowPosition } = useReactFlow();
const nodeTypes = useMemo(() => ({
entityNode: EntityNodeComponent
}), []);
const edgeTypes = useMemo(() => ({
actionEdge: ActionEdgeComponent
}), []);
// If there is a higlighted segment, then we only show the edges from that segment
const actionsFilter = highlightedActionsSegment ? highlightedActionsSegment : filteredActionsSegment;
let filteredEdges = getFilteredActionEdges(actionsFilter);
if (highlightedEntities.length > 0 || selectedNodes.length > 0) {
const entitiesToConsider = highlightedEntities.concat(selectedNodes);
// If there are highlighted entities, we only show the edges that are connected to them
filteredEdges = filteredEdges.filter(edge => entitiesToConsider.includes(edge.source) || entitiesToConsider.includes(edge.target));
}
useEffect(() => {
// Select the input and clear it
if (inputField && inputFieldRef.current) {
inputFieldRef.current.focus();
inputFieldRef.current.value = "";
}
}, [inputField]);
return (
<>
<div ref={divRef} style={{ position: 'relative', width: '100%', height: '100%'}}>
<ReactFlow
nodes={entityNodes}
edges={filteredEdges}
connectionMode={ConnectionMode.Loose}
nodeTypes={nodeTypes as any}
edgeTypes={edgeTypes as any}
deleteKeyCode={[]}
zoomOnDoubleClick={false}
onSelectionChange={(selection) => {
if (!useModelStore.getState().isReadOnly) {
setSelectedNodes(selection.nodes.map(node => node.id));
}
setSelectedEdges(selection.edges.map(edge => edge.id));
}}
onNodesChange={(changes) => {
setEntityNodes(applyNodeChanges(changes, entityNodes))
}}
onEdgesChange={(changes) => {
setActionEdges(applyEdgeChanges(changes, actionEdges))
}}
onMouseMove={(event) => {
setCurrentMousePosition({x: event.clientX, y: event.clientY});
}}
onConnect={(params) => {
if (isReadOnly) return;
const sourceNode = entityNodes.find(node => node.id === params.source);
const targetNode = entityNodes.find(node => node.id === params.target);
setInputField({
placeholder: "Enter the action name",
x: currentMousePosition.x - divRef.current!.getBoundingClientRect().left,
y: currentMousePosition.y - divRef.current!.getBoundingClientRect().top,
onValidate: (text) => {
new AddActionPrompt(sourceNode!.data, targetNode!.data, text).execute();
const edge : ActionEdge = {
type: "actionEdge",
label: text,
sourceHandle: params.sourceHandle,
targetHandle: params.targetHandle,
animated: true,
markerEnd: { type: "arrowclosed", width: 25, height: 25} as any,
source: params.source,
target: params.target,
data: { name: text, passage: "", sourceLocation: "unknown", targetLocation: "unknown" },
id: "action-" + text
}
setActionEdges(addEdge(edge, actionEdges))
}
});
}}
onDoubleClick={(event) => {
if (!useModelStore.getState().isReadOnly && (event.target as HTMLElement).classList.contains("react-flow__pane") && divRef.current) {
setInputField(
{
placeholder: "Enter the entity name",
x: event.clientX - divRef.current!.getBoundingClientRect().left,
y: event.clientY - divRef.current!.getBoundingClientRect().top,
onValidate: (text) => {
const entity = {name: text, emoji: "", properties: []};
const entityNode = CreateEntityNode(entity, 0);
entityNode.position = screenToFlowPosition({x: event.clientX, y: event.clientY}, {snapToGrid: false});
const newEntityNodes = [...entityNodes, entityNode];
setEntityNodes(newEntityNodes)
LayoutUtils.optimizeNodeLayout("entity", newEntityNodes, setEntityNodes, {x: divRef.current!.clientWidth/2, y: divRef.current!.clientHeight/2}, 120, 100);
}
});
}
}}
>
<Controls />
<Background variant={BackgroundVariant.Dots} gap={12} size={1} />
</ReactFlow>
{inputField && <div style={{ position: 'absolute', top: inputField.y, left: inputField.x, zIndex: 999, transform: 'translate(-50%, -50%)', background: 'white', padding: 10, borderRadius: 5, boxShadow: 'rgba(0, 0, 0, 0.24) 0px 3px 8px' }}>
<input ref={inputFieldRef} type="text" placeholder={inputField.placeholder}
onBlur={() => {
setInputField(null);
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
inputField.onValidate(inputFieldRef.current!.value);
setInputField(null);
} else if (event.key === "Escape") {
setInputField(null);
}
}} /></div>}
{!isReadOnly && <span style={{position: 'absolute', bottom: 5, left: '50%', transform: 'translate(-50%, 0%)', pointerEvents: 'none', color: '#888'}}>Double click to create a new entity</span>}
</div>
</>
)
}
@@ -0,0 +1,129 @@
import { Handle, NodeProps, Position, useKeyPress, useReactFlow } from '@xyflow/react';
import { useCallback, useEffect } from 'react';
import { Slider } from '@nextui-org/react';
import '@xyflow/react/dist/style.css';
import { Entity, EntityNode, useModelStore } from '../../model/Model';
import { ChangePropertyPrompt } from '../../model/prompts/textEditors/ChangePropertyPrompt';
import { RemoveEntityPrompt } from '../../model/prompts/textEditors/RemoveEntityPrompt';
export function CreateEntityNode(entity: Entity, index: number): EntityNode {
const x = index % 2;
const y = Math.floor(index / 2);
return {
id: "entity-" + entity.name,
type: "entityNode",
dragHandle: '.custom-drag-handle',
measured: { width: 160, height: 160 },
position: { x: 20 + x * 350, y: 20 + y * 200 },
data: { ...entity }
}
}
export default function EntityNodeComponent(props: NodeProps<EntityNode>) {
const handleStyle = { background: 'white', border: '1px solid #c8c8c8', width: 7, height: 7 };
const isSelected = useModelStore(state => state.selectedNodes.includes(props.id));
const { setNodes, setEdges } = useReactFlow();
const deletePressed = useKeyPress(["Delete", "Backspace"]);
const entityNodes = useModelStore(state => state.entityNodes);
const setEntityNodes = useModelStore(state => state.setEntityNodes);
const getFilteredEntityNodes = useModelStore(state => state.getFilteredEntityNodes);
const highlightedEntities = useModelStore(state => state.highlightedEntities);
const isReadOnly = useModelStore(state => state.isReadOnly);
const highlightedActionsSegment = useModelStore(state => state.highlightedActionsSegment);
const filteredActionsSegment = useModelStore(state => state.filteredActionsSegment);
let isFaded = false;
if (highlightedActionsSegment) {
const filteredEntities = getFilteredEntityNodes(highlightedActionsSegment);
isFaded = !filteredEntities.map(entity => entity.id).includes(props.id);
}
if (!isFaded && filteredActionsSegment) {
const filteredEntities = getFilteredEntityNodes(filteredActionsSegment);
isFaded = !filteredEntities.map(entity => entity.id).includes(props.id);
}
if (!isFaded && highlightedEntities.length > 0 && highlightedEntities.indexOf(props.id) === -1) {
// Fade if no actions are connected to this entity
isFaded = useModelStore.getState().actionEdges.filter(edge => highlightedEntities.includes(edge.source) && edge.target === props.id
|| highlightedEntities.includes(edge.target) && edge.source === props.id).length === 0;
}
if (!isReadOnly) {
useEffect(() => {
if (deletePressed && useModelStore.getState().selectedNodes.includes(props.id)) {
// Also remove the edges that had this node as a source or target
setEdges((edges) => edges.filter((edge) => edge.source !== props.id && edge.target !== props.id));
// Remove the node
setNodes((nodes) => nodes.filter((node) => node.id !== props.id));
// Modify the story accordingly by executing a prompt
new RemoveEntityPrompt(props.data).execute()
}
}, [deletePressed])
}
const onPropertySliderChanged = useCallback((property: string, newValue: number, triggerPrompt: boolean = false) => {
const nodeToModify = entityNodes.find(node => node.id === props.id) as EntityNode;
if (nodeToModify) {
let previousValue = 0;
nodeToModify.data.properties = nodeToModify.data.properties.map(p => {
if (p.name === property) {
previousValue = p.value;
p.value = newValue;
}
return p;
});
setEntityNodes([...entityNodes]);
if (triggerPrompt) {
// Also trigger a prompt to modify the story
new ChangePropertyPrompt(nodeToModify.data, property, previousValue, newValue).execute()
}
}
}, [entityNodes, setEntityNodes])
const propertySliders = props.data.properties.map(property => {
return <div className="nodrag nopan" style={{ display: 'flex', flexDirection: 'column' }} key={`property-${props.data.name}-${property.name}`}>
<Slider size='sm' label={property.name} className="max-w-md" step={1} color='primary' minValue={1} maxValue={10} defaultValue={property.value}
onChangeEnd={(newValue) => onPropertySliderChanged(property.name, newValue as number, true)}>
</Slider>
</div>
})
return <>
<div className='custom-drag-handle node-entity' style={{ position: 'relative', border: `1px solid ${isSelected ? '#4180d9' : 'white'}`, boxShadow: 'rgba(0, 0, 0, 0.24) 0px 3px 8px', padding: 10, background: 'white', borderRadius: 5, opacity: isFaded ? '0.4' : 1 }}
onMouseEnter={(event) => {
useModelStore.getState().setHighlightedEntities([props.id]);
}}
onMouseLeave={(event) => {
useModelStore.getState().setHighlightedEntities([]);
}}
>
<Handle style={handleStyle} type="source" id="t" position={Position.Top} />
<Handle style={handleStyle} type="source" id="b" position={Position.Bottom} />
<Handle style={handleStyle} type="source" id="l" position={Position.Left} />
<Handle style={handleStyle} type="source" id="r" position={Position.Right} />
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', justifyContent: 'left', minWidth: 130, minHeight: 50 }}>
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', marginRight: 10, background: 'rgb(243 244 246)', width: 40, height: 40, borderRadius: 99999 }}>
{props.data.emoji}
</div>
<span style={{ fontWeight: 800 }}>{props.data.name} </span>
</div>
{!isReadOnly && isSelected && <div style={{position: 'absolute', zIndex: 99999, border: '1px solid #e5e7eb', width: '100%', top: '100%', left: 0, background: 'white', padding: 10, borderRadius: 5, boxShadow: 'rgba(0, 0, 0, 0.24) 0px 3px 8px'}}>
{propertySliders}
</div>}
</div>
</>
}
@@ -0,0 +1,55 @@
import { NodeProps, useKeyPress } from '@xyflow/react';
import { useEffect } from 'react';
import '@xyflow/react/dist/style.css';
import { Location, LocationNode, useModelStore } from '../../model/Model';
import { useViewModelStore } from '../../model/ViewModel';
export function CreateLocatioNode(location: Location, index: number): LocationNode {
const x = index % 2;
const y = Math.floor(index / 2);
return {
id: `location-${index}`,
dragHandle: '.custom-drag-handle',
type: "locationNode",
measured: {width: 160, height: 160},
position: { x: 20 + x * 350, y: 20 + y * 200 },
data: location
}
}
export default function LocationNodeComponent(props: NodeProps<LocationNode>) {
const deletePressed = useKeyPress(["Delete", "Backspace"]);
const getFilteredLocationNodes = useModelStore(state => state.getFilteredLocationNodes);
const hoveredLocation = useViewModelStore(state => state.hoveredLocation);
let isHovered = hoveredLocation === props.data.name;
const highlightedActionsSegment = useModelStore(state => state.highlightedActionsSegment);
let isFaded = false;
if (highlightedActionsSegment) {
const filteredLocations = getFilteredLocationNodes(highlightedActionsSegment);
isFaded = !filteredLocations.map(l => l.id).includes(props.id);
}
useEffect(() => {
if (deletePressed && useModelStore.getState().selectedNodes.includes(props.id)) {
// TODO
}
}, [deletePressed])
return <>
<div className='custom-drag-handle' style={{ border: `1px solid ${isHovered ? 'blue' : 'white'}`, boxShadow: 'rgba(0, 0, 0, 0.24) 0px 3px 8px', width: 160, height: 160, padding: 10, background: 'white', borderRadius: 9999, opacity: isFaded ? '0.3' : 1 }}>
<div style={{ display: 'flex', height: '100%', flexDirection: 'row', alignItems: 'end', justifyContent: 'center' }}>
<span style={{ fontWeight: 800, transform: 'translate(0%, 38px)', whiteSpace: 'nowrap' }}>{props.data.emoji} {props.data.name}</span>
</div>
</div>
</>
}
+301
View File
@@ -0,0 +1,301 @@
import { Background, BackgroundVariant, Controls, Node, NodeProps, ReactFlow, applyNodeChanges, useReactFlow } from '@xyflow/react';
import { useEffect, useMemo, useRef, useState } from 'react';
import '@xyflow/react/dist/style.css';
import { forceCollide, forceSimulation, forceX, forceY } from 'd3-force';
import { LayoutUtils } from '../../model/LayoutUtils';
import { Entity, useModelStore } from '../../model/Model';
import { useViewModelStore } from '../../model/ViewModel';
import { MoveEntityPrompt } from '../../model/prompts/textEditors/MoveEntityPrompt';
import LocationNodeComponent, { CreateLocatioNode } from './LocationNodeComponent';
export type SpatialEntity = {
location: string;
} & Entity;
export type SpatialEntityNode = Node<SpatialEntity>;
function SpatialEntityNodeComponent(props: NodeProps<SpatialEntityNode>) {
const isSelected = useModelStore(state => state.selectedNodes.includes(props.id));
const filteredActionsSegment = useModelStore(state => state.filteredActionsSegment);
const highlightedActionsSegment = useModelStore(state => state.highlightedActionsSegment);
let filter = filteredActionsSegment || highlightedActionsSegment;
const filteredEntities = useModelStore.getState().getFilteredEntityNodes(filter);
let isFaded = !filteredEntities.map(entity => entity.data.name).includes(props.data.name);
return <>
<div className='custom-drag-handle' style={{position: 'relative', zIndex: 999, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', border: `1px solid ${isSelected ? 'blue' : 'white'}`, width: 50, height: 50, boxShadow: 'rgba(0, 0, 0, 0.24) 0px 3px 8px', padding: 10, background: 'white', borderRadius: 999, opacity: isFaded ? '0.3' : 1 }}>
<span style={{fontSize: 24}}>{props.data.emoji}</span>
<div style={{position: 'relative'}}>
<span style={{ whiteSpace: 'nowrap', fontWeight: 400, padding: 1, position: 'absolute', borderRadius: 3, background: 'white', transform: 'translate(-50%, 0%)', boxShadow: 'rgba(0, 0, 0, 0.24) 0px 3px 8px', top: -4, left: 0, fontSize: 10 }}>{props.data.name} </span>
</div>
</div>
</>
}
export default function LocationsEditor() {
const entityNodes = useModelStore(state => state.entityNodes);
const locationNodes = useModelStore(state => state.locationNodes);
const getFilteredActionEdges = useModelStore(state => state.getFilteredActionEdges);
const setLocationNodes = useModelStore(state => state.setLocationNodes);
const [spatialEntityNodes, setSpatialEntityNodes] = useState<SpatialEntityNode[]>([]);
const filteredActionsSegment = useModelStore(state => state.filteredActionsSegment);
const highlightedActionsSegment = useModelStore(state => state.highlightedActionsSegment);
const [canSimulateForce, setCanSimulateForce] = useState(true);
const [newLocationInputPosition, setNewLocationInputPosition] = useState<{ x: number, y: number } | null>(null);
const locationInputRef = useRef<HTMLInputElement>(null);
const divRef = useRef<HTMLDivElement>(null);
const { getIntersectingNodes, screenToFlowPosition } = useReactFlow();
const isReadOnly = useModelStore(state => state.isReadOnly);
if (entityNodes.length > 0 && locationNodes.length === 0) {
const unkownLocation = CreateLocatioNode({ name: "unknown", emoji: "" }, 0);
setLocationNodes([unkownLocation]);
}
useMemo(() => {
if (!canSimulateForce) {
LayoutUtils.stopSimulation('spatial-nodes');
return;
}
let onlyLastKnownLocations = false;
let filter = filteredActionsSegment;
if (filteredActionsSegment) {
onlyLastKnownLocations = true;
filter = { start: 0, end: filteredActionsSegment.end }
}
if (highlightedActionsSegment) {
onlyLastKnownLocations = true;
filter = { start: 0, end: highlightedActionsSegment.end }
}
const filteredActionEdges = getFilteredActionEdges(filter);
// Retrieve all entities and their locations based on the actions
const entityLastKnownLocation: { [key: string]: string } = {};
const entityVisitedLocations : { [key: string]: string[] } = {};
for (const actionEdge of filteredActionEdges) {
const locations = [
{ location: locationNodes.find(location => location.data.name === actionEdge.data?.sourceLocation), entityAtLocation: actionEdge.source },
{ location: locationNodes.find(location => location.data.name === actionEdge.data?.targetLocation), entityAtLocation: actionEdge.target }
];
locations.forEach(({ location, entityAtLocation }) => {
if (location) {
const entity = entityNodes.find(e => e.id === entityAtLocation);
if (entity) {
const entityName = entity.data.name;
if (!entityVisitedLocations[entityName]) {
entityVisitedLocations[entityName] = [];
}
entityVisitedLocations[entityName].push(location.data.name);
entityLastKnownLocation[entityName] = location.data.name;
}
}
});
}
// Now create the spatialEntityNodes
const newSpatialEntityNodes: SpatialEntityNode[] = [];
for (const [entity, locations] of Object.entries(entityVisitedLocations)) {
const locationsToConsider = onlyLastKnownLocations ? [locations[locations.length-1]] : new Set(locations);
for (const location of locationsToConsider) {
const locationNode = locationNodes.find(node => node.data.name === location);
const entityNode = entityNodes.find(node => node.data.name === entity);
const id = `spatial-entity-${entity}-${onlyLastKnownLocations ? "" : location}`;
const previousNode = spatialEntityNodes.find(node => node.id === id);
if (locationNode && entityNode) {
const locationWidth = locationNode?.measured?.width || 0;
const locationHeight = locationNode?.measured?.height || 0;
newSpatialEntityNodes.push({
...(previousNode? previousNode : {}),
id: id,
draggable: !isReadOnly,
selectable: !isReadOnly,
type: "spatialEntityNode",
dragHandle: '.custom-drag-handle',
measured: { width: 50, height: 50 }, // Since ReactFlow 12, not giving this value results in some NaN value when dragging
position: { x: previousNode?.position.x || locationNode.position.x + locationWidth/2-25, y: previousNode?.position.y || locationNode.position.y + locationHeight/2 - 25 },
data: {...entityNode.data, location: location}
})
}
}
}
// Run a force simulation to properly position the entities
const nodes = newSpatialEntityNodes.map(node => {
const location = useModelStore.getState().locationNodes.find(location => location.data.name === node.data.location);
const targetX = ((location?.position.x || 0) + (location?.measured?.width || 0)/2);
const targetY = ((location?.position.y || 0) + (location?.measured?.height || 0)/2);
return { id: node.id, x: node.position.x, y: node.position.y, data: node, cx: targetX, cy: targetY }
});
const simulation = forceSimulation(nodes)
.force("x", forceX(d => d.cx - (d.data?.measured?.width || 0)/2))
.force("y", forceY(d => d.cy - (d.data?.measured?.height || 0)/2))
.force("collide", forceCollide(30))
.tick(1) // Because we might be restarting the simulation, we want to make sure we are not starting from scratch because it would cause some jittering
LayoutUtils.startSimulation('spatial-nodes', simulation as any, () => {
let stable = false;
if (spatialEntityNodes.length === nodes.length) {
stable = true;
nodes.forEach((node, i) => {
let dx = node.x - spatialEntityNodes[i].position.x;
let dy = node.y - spatialEntityNodes[i].position.y;
let movement = Math.sqrt(dx * dx + dy * dy);
if (Math.abs(movement) > 0.08) {
stable = false;
}
});
}
if (!stable) {
setSpatialEntityNodes(nodes.map(node => {
return node.data.dragging ? node.data : { ...node.data, position: { x: node.x, y: node.y } }
}))
} else {
// Stop early, seems like there will not be any more movement
LayoutUtils.stopSimulation('spatial-nodes');
}
}, 100);
}, [filteredActionsSegment, highlightedActionsSegment, spatialEntityNodes, locationNodes, canSimulateForce]);
useEffect(() => {
// Give the focus to the input
if (locationInputRef.current && newLocationInputPosition) {
// clear it as well
locationInputRef.current.value = "";
locationInputRef.current.focus();
}
}, [newLocationInputPosition]);
const nodeTypes = useMemo(() => ({
spatialEntityNode: SpatialEntityNodeComponent,
locationNode: LocationNodeComponent
}), []);
return (
<>
<div ref={divRef} style={{ position: 'relative', width: '100%', height: '100%' }}>
<ReactFlow
nodes={[...locationNodes, ...spatialEntityNodes]}
nodeTypes={nodeTypes as any}
deleteKeyCode={[]}
zoomOnDoubleClick={false}
/*onSelectionChange={(selection) => {
}}*/
onNodesChange={(changes) => {
const shouldChange = changes.some(change => change.type === 'position');
if (shouldChange) {
//setCanSimulateForce(false);
// @ts-ignore
setSpatialEntityNodes(applyNodeChanges(changes, spatialEntityNodes))
setLocationNodes(applyNodeChanges(changes, locationNodes))
}
}}
onDoubleClick={(event) => {
// get x and y relative to the divRef
if (!useModelStore.getState().isReadOnly && (event.target as HTMLElement).classList.contains("react-flow__pane") && divRef.current) {
const x = event.clientX - divRef.current?.getBoundingClientRect().left;
const y = event.clientY - divRef.current?.getBoundingClientRect().top;
setNewLocationInputPosition({ x: x, y: y });
}
}}
onNodeDragStart={() => {
setCanSimulateForce(false);
}}
onNodeDrag={(event, node) => {
const intersections = getIntersectingNodes(node);
let resetHoveredElement = true;
if (node.type === "spatialEntityNode") {
for (const intersection of intersections) {
if (intersection.type === "locationNode") {
useViewModelStore.getState().setHoveredLocation(intersection.data?.name as any);
resetHoveredElement = false;
}
}
if (resetHoveredElement) {
useViewModelStore.getState().setHoveredLocation(null);
}
}
}}
onNodeDragStop={(event, node) => {
const hoveredLocation = useViewModelStore.getState().hoveredLocation;
if (node.type === "spatialEntityNode" && hoveredLocation) {
const spatialEntityNode = (node as SpatialEntityNode);
const locationNode = locationNodes.find(location => location.data.name === hoveredLocation);
if (locationNode && hoveredLocation !== spatialEntityNode.data.location) {
// Update the id of the spatialEntityNode so that it matches the new simuation
const previousId = spatialEntityNode.id;
spatialEntityNode.id = `spatial-entity-${spatialEntityNode.data.name}-${hoveredLocation}`;
const newSpatialEntityNodes = spatialEntityNodes.map(node => node.id === previousId ? spatialEntityNode : node);
setSpatialEntityNodes([...newSpatialEntityNodes]);
new MoveEntityPrompt(spatialEntityNode.data, locationNode.data).execute();
}
}
setCanSimulateForce(true);
useViewModelStore.getState().setHoveredLocation(null);
}}
>
<Controls />
<Background variant={BackgroundVariant.Dots} gap={12} size={1} />
</ReactFlow>
{newLocationInputPosition && <div style={{ position: 'absolute', top: newLocationInputPosition.y, left: newLocationInputPosition.x, zIndex: 999, transform: 'translate(-50%, -50%)', background: 'white', padding: 10, borderRadius: 5, boxShadow: 'rgba(0, 0, 0, 0.24) 0px 3px 8px' }}>
<input ref={locationInputRef} autoFocus type="text" placeholder="Enter the location name"
onBlur={() => {
setNewLocationInputPosition(null);
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
const location = event.currentTarget.value;
const pos = screenToFlowPosition({x: newLocationInputPosition.x + divRef.current!.getBoundingClientRect().left, y: newLocationInputPosition.y + divRef.current!.getBoundingClientRect().top}, {snapToGrid: false});
const newLocatioNodes = [...locationNodes, { id: `location-${location}`, type: "locationNode", measured: { width: 160, height: 160 }, position: { x: pos.x, y: pos.y }, data: { name: location, emoji: "" } }]
setLocationNodes(newLocatioNodes)
setNewLocationInputPosition(null);
LayoutUtils.optimizeNodeLayout("location", newLocatioNodes, setLocationNodes, { x: divRef.current!.clientWidth/2, y: divRef.current!.clientHeight/2 }, 120);
} else if (event.key === "Escape") {
setNewLocationInputPosition(null);
}
}} /></div>}
{!isReadOnly && <span style={{position: 'absolute', bottom: 5, left: '50%', transform: 'translate(-50%, 0%)', pointerEvents: 'none', color: '#888'}}>Double click to create a new location</span>}
</div>
</>
)
}
+102
View File
@@ -0,0 +1,102 @@
// from https://reactflow.dev/examples/edges/floating-edges
import { Position, MarkerType } from '@xyflow/react';
// this helper function returns the intersection point
// of the line between the center of the intersectionNode and the target node
function getNodeIntersection(intersectionNode, targetNode) {
// https://math.stackexchange.com/questions/1724792/an-algorithm-for-finding-the-intersection-point-between-a-center-of-vision-and-a
const { width: intersectionNodeWidth, height: intersectionNodeHeight } =
intersectionNode.measured;
const intersectionNodePosition = intersectionNode.internals.positionAbsolute;
const targetPosition = targetNode.internals.positionAbsolute;
const w = intersectionNodeWidth / 2;
const h = intersectionNodeHeight / 2;
const x2 = intersectionNodePosition.x + w;
const y2 = intersectionNodePosition.y + h;
const x1 = targetPosition.x + targetNode.measured.width / 2;
const y1 = targetPosition.y + targetNode.measured.height / 2;
const xx1 = (x1 - x2) / (2 * w) - (y1 - y2) / (2 * h);
const yy1 = (x1 - x2) / (2 * w) + (y1 - y2) / (2 * h);
const a = 1 / (Math.abs(xx1) + Math.abs(yy1));
const xx3 = a * xx1;
const yy3 = a * yy1;
const x = w * (xx3 + yy3) + x2;
const y = h * (-xx3 + yy3) + y2;
return { x, y };
}
// returns the position (top,right,bottom or right) passed node compared to the intersection point
function getEdgePosition(node, intersectionPoint) {
const n = { ...node.internals.positionAbsolute, ...node };
const nx = Math.round(n.x);
const ny = Math.round(n.y);
const px = Math.round(intersectionPoint.x);
const py = Math.round(intersectionPoint.y);
if (px <= nx + 1) {
return Position.Left;
}
if (px >= nx + n.measured.width - 1) {
return Position.Right;
}
if (py <= ny + 1) {
return Position.Top;
}
if (py >= n.y + n.measured.height - 1) {
return Position.Bottom;
}
return Position.Top;
}
// returns the parameters (sx, sy, tx, ty, sourcePos, targetPos) you need to create an edge
export function getEdgeParams(source, target) {
const sourceIntersectionPoint = getNodeIntersection(source, target);
const targetIntersectionPoint = getNodeIntersection(target, source);
const sourcePos = getEdgePosition(source, sourceIntersectionPoint);
const targetPos = getEdgePosition(target, targetIntersectionPoint);
return {
sx: sourceIntersectionPoint.x,
sy: sourceIntersectionPoint.y,
tx: targetIntersectionPoint.x,
ty: targetIntersectionPoint.y,
sourcePos,
targetPos,
};
}
export function initialElements() {
const nodes = [];
const edges = [];
const center = { x: window.innerWidth / 2, y: window.innerHeight / 2 };
nodes.push({ id: 'target', data: { label: 'Target' }, position: center });
for (let i = 0; i < 8; i++) {
const degrees = i * (360 / 8);
const radians = degrees * (Math.PI / 180);
const x = 250 * Math.cos(radians) + center.x;
const y = 250 * Math.sin(radians) + center.y;
nodes.push({ id: `${i}`, data: { label: 'Source' }, position: { x, y } });
edges.push({
id: `edge-${i}`,
target: 'target',
source: `${i}`,
type: 'floating',
markerEnd: {
type: MarkerType.Arrow,
},
});
}
return { nodes, edges };
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+14
View File
@@ -0,0 +1,14 @@
import { nextui } from '@nextui-org/react';
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
"./node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [nextui()],
}
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"allowJs": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+11
View File
@@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
}
]
}
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true,
"noEmit": true
},
"include": ["vite.config.ts"]
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
build: {
sourcemap: false,
outDir: 'build',
minify: 'esbuild',
}
})