diff --git a/src/js/src/bookmark/app.html b/src/js/src/bookmark/app.html
new file mode 100644
index 0000000..0058229
--- /dev/null
+++ b/src/js/src/bookmark/app.html
@@ -0,0 +1,129 @@
+
+
+
+
+
+
+ Solitude
+
+
+
+
+
+
+ - グラフ閲覧
+ - メニュー表示
+ - テーブル編集
+ - DOTソースコード
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/js/src/bookmark/main.css b/src/js/src/bookmark/main.css
new file mode 100644
index 0000000..275da42
--- /dev/null
+++ b/src/js/src/bookmark/main.css
@@ -0,0 +1,131 @@
+:root {
+ --main-border-color: brown;
+ font-size: 16px;
+ }
+
+p {
+ margin: 0px;
+}
+
+ul {
+ padding-left: 1em;
+ padding-right: 1em;
+ margin: 5px;
+}
+
+div.app-body {
+ /*
+ display: grid;
+ grid-template-columns: 1fr;
+ */
+ display: block flex;
+ min-width: 640px;
+ min-height: 480px;
+}
+
+div.app-body div.left-pane {
+ min-width: 200px;
+ max-width: 250px;
+ overflow-x: scroll;
+ width: 30%;
+
+ height: 100%;
+ background-color: green;
+}
+
+div.app-body div.right-pane {
+ min-width: 400px;
+ min-height: 100px;
+ overflow-x: scroll;
+
+ height: 100%;
+
+}
+
+div.top-bar, div.bottom-bar {
+ height: 1em; ;
+ border: 0.1em solid var(--main-border-color);
+}
+
+#mainView {
+ height: 30vh;
+ overflow-x: scroll;
+ overflow-y: scroll;
+}
+
+#mainView p {
+ width: fit-content;
+}
+
+#ref-map {
+ width: 70vw;
+ overflow: scroll;
+ object-fit: scale-down;
+}
+
+#editMenu {
+ display: flex;
+ list-style: none;
+}
+
+div.editor.shrinked {
+ /* expanded -> shrinked*/
+ animation: 1s linear 0.2s reverse editor-expanding ;
+ max-height: 0vh;
+ overflow: hidden;
+}
+
+div.editor.expanded {
+ /* shrinked -> expanded */
+ animation: 1s linear 0.2s normal editor-expanding ;
+ max-height: 60vh;
+ overflow: scroll;
+
+}
+
+div.editor input[readonly=""] {
+ background-color: lightsteelblue;
+}
+
+div.editor div.ref-card {
+ border-style: solid;
+ border-color: orange;
+ border-width: 0.1em;
+}
+
+
+div.editor div.ref-card li{
+ display: flex;
+}
+
+div.editor div.ref-card li span{
+ width:20%;
+}
+
+div.editor div.ref-card input{
+ width:80%;
+}
+
+div.editor div.ref-card ul.card-menu {
+ display:flex;
+}
+
+#dot-src-viewer, #csv-src-viewer {
+ display: block;
+ min-width: 400px;
+ min-height: 20vh;
+ width: 70vw;
+}
+
+/*
+@keyframes editor-expanding {
+ 0% {
+ max-height: 0vh;
+ overflow: hidden;
+ }
+ 100% {
+ max-height: 100vh;
+ overflow: hidden;
+ }
+}
+*/
\ No newline at end of file
diff --git a/src/js/src/bookmark/main.js b/src/js/src/bookmark/main.js
new file mode 100644
index 0000000..40756e9
--- /dev/null
+++ b/src/js/src/bookmark/main.js
@@ -0,0 +1,278 @@
+import { Graphviz } from "https://cdn.jsdelivr.net/npm/@hpcc-js/wasm/dist/index.js";
+
+let counter = 0;
+
+// Graphviz ---
+
+(async function() {
+ document.querySelector("#ref-map").setAttribute("data", await dot2svg());
+ document.querySelector("#dot-src-viewer").value = card2dot();
+ const dataUri = await dot2svg(document.querySelector("#dot-src-viewer").value);
+ document.querySelector("#ref-map").setAttribute("data", dataUri);
+})();
+
+window.addEventListener("load", (event) => {
+ const editor = document.querySelector("#menuView");
+ editor.addEventListener("click", handleAppendCardButton)
+ editor.addEventListener("click", handleDeleteCardButton)
+ editor.addEventListener("click", handleLoadCsvButton)
+ editor.addEventListener("click", handleSaveButton)
+ editor.addEventListener("click", handleExportButton)
+
+ const geneBtn = document.querySelector("#generate");
+ geneBtn.addEventListener("click",handleGenerateButton);
+
+ function handleAppendCardButton(event) {
+ if(event.target.classList.contains("card-append")){
+ const bigBrotherCard = event.target.closest("div.ref-card");
+ appendCard(bigBrotherCard, {
+ id: counter++ + "",
+ parent: "root",
+ type: "folder",
+ label: "nonelabel",
+ url: "https://www.exmple.com/"
+ })
+ }
+ }
+
+ function handleGenerateButton(event) {
+ document.querySelector("#dot-src-viewer").value = card2dot();
+ dot2svg(document.querySelector("#dot-src-viewer").value)
+ .then(dataUri => document.querySelector("#ref-map").setAttribute("data", dataUri));
+ }
+
+ function handleDeleteCardButton(event) {
+ if(event.target.classList.contains("card-delete")) {
+ setTimeout(() => {
+ deleteCard(event.target.closest("div.ref-card"));
+ } ,50);
+ }
+ }
+
+ function handleLoadCsvButton(event) {
+ if(event.target.id != "loadCsv") {
+ return;
+ }
+
+ for (const elem of document.querySelectorAll("button.card-delete")) {
+
+ setTimeout(() => {
+ deleteCard(elem.closest("div.ref-card"));
+ }, 50);
+ };
+
+ csv2card(document.querySelector("#csv-src-viewer").value);
+ }
+
+ async function handleSaveButton(event) {
+ if(event.target.id !="save") {
+ return;
+ }
+ const save = document.querySelector("#savelink");
+ const data = await dot2svg(card2dot());
+
+ save.setAttribute("href", data);
+ save.setAttribute("download", "result.svg");
+ save.click();
+ }
+
+ function handleExportButton(event) {
+ if (event.target.id != "export") {
+ return;
+ }
+
+ download(card2csv(),"text/csv", "favorite.csv",);
+ }
+
+ function download(text, mimeType, name,) {
+ const save = document.querySelector("#savelink");
+ const encoded = window.btoa(unescape(encodeURIComponent(text)))
+ const dataUri = `data:${mimeType};charset=UTF-8;base64,${encoded}`
+
+ save.setAttribute("href", dataUri);
+ save.setAttribute("download", name);
+ setTimeout(() => {
+ save.click();
+ } ,50);
+ }
+});
+
+
+function appendCard(bigbrother, node = {}) {
+ const newCard = document.querySelector("#tmpl-ref-card").content.cloneNode(true);
+ bigbrother.parentNode.insertBefore(newCard, bigbrother.nextElementSibling);
+
+ const addedCard = bigbrother.nextElementSibling;
+
+ const inputs = addedCard.querySelectorAll("input");
+ const {id, parent, type, label, url} = node;
+ if (id && id.length > 0) {
+ inputs[0].value = id
+ }
+
+ if (parent && parent.length > 0) {
+ inputs[1].value = parent
+ }
+
+ if (type && type.length > 0) {
+ inputs[2].value = type
+ }
+
+ if (label && label.length > 0) {
+ inputs[3].value = label
+ }
+
+
+ if (url && url.length > 0) {
+ inputs[4].value = url
+ }
+}
+
+function deleteCard(card) {
+ card.parentNode.removeChild(card);
+}
+
+function csv2card(csv) {
+ //改行をLFだけにしたい
+ const text = csv.replaceAll("\r", "");
+ const textRows = text.split("\n")
+ const rows = [];
+ //当該要素はrootの一つしか残っていない想定
+ let bigBrother = document.querySelector("div.ref-card") ;
+ for(const text of textRows) {
+ //ヘッダは読み飛ばす
+ if("id,parent,type,label,url" == text || "" == text.trim()) {
+ continue;
+ }
+
+ const row = text.split(",")
+
+ if(row[0] == "root") {
+ continue;
+ }
+
+ appendCard(bigBrother, {
+ id: row[0],
+ parent: row[1],
+ type: row[2],
+ label: row[3],
+ url: row[4]
+ })
+
+ let cards = document.querySelectorAll("div.ref-card");
+ bigBrother = cards[cards.length - 1]
+ }
+
+}
+function card2dot() {
+ const nodes = [];
+ const edges = [];
+
+
+ function digraph(nodes, edges) {
+ const dot = `digraph G {
+ graph [overlap="true", bgcolor="#ffffdd", mindist="0.01"];
+ node [fontname="BIZUDP Gothic" target="_blank", shape="tab", style="filled", color="#cc6666", fontcolor="#333333", fillcolor="#ffcc90"];
+ edge [fontname="meiryo", color="#cc6666"];
+
+
+ ${nodes}
+
+ ${edges}
+}
+`;
+ return dot;
+ }
+
+ const nodeList = [];
+ const edgeList = [];
+
+ const cards = document.querySelectorAll("div.ref-card");
+
+ const id = 0, parent=1, type=2, label=3, url=4;
+ for(const card of cards) {
+ const values = card.querySelectorAll("input");
+ const refNode = {};
+
+ refNode.id = values[id].value;
+ refNode.parent = values[parent].value;
+ refNode.type = values[type].value;
+ refNode.label = values[label].value;
+ refNode.url = values[url].value;
+
+ nodeList.push(refNode);
+ }
+ for (const refNode of nodeList) {
+ const id = refNode.id,
+ parent = refNode.parent,
+ type = refNode.type,
+ label = refNode.label,
+ url = refNode.url;
+
+ let shapeStmt;
+ if(type == "folder") {
+ shapeStmt = `, shape="ellipse"`;
+ } else {
+ shapeStmt = ""
+ }
+
+ let labelStmt;
+ if(label == "") {
+ labelStmt = `label="${id}"`;
+ } else {
+ labelStmt = `label="${label}"`;
+ }
+
+ let hrefStmt;
+ if(url == "") {
+ hrefStmt = "";
+ } else {
+ //label属性が必ずあるため区切りの, を決め打ちで入れて問題なし
+ hrefStmt = `, href="${url}"`;
+ }
+
+ const nodeStmt = `${id} [${labelStmt}${hrefStmt}${shapeStmt}];`;
+ nodes.push(nodeStmt);
+
+ if(id == "root") {
+ continue;
+ }
+
+ const edgeStmt = `${parent} -> ${id};`;
+ edges.push(edgeStmt);
+ }
+ return digraph(nodes.join("\n "),edges.join("\n "));
+}
+
+async function dot2svg(dot = "") {
+ const graphviz = await Graphviz.load();
+ const src = dot == "" ? 'digraph G { Hello -> World }' : dot;
+ const graph = graphviz.circo(src);
+ //const graph = graphviz.neato(src);
+ const encoded = window.btoa(unescape(encodeURIComponent(graph)))
+ const dataUri = `data:image/svg+xml;charset=UTF-8;base64,${encoded}`
+ return dataUri
+}
+
+function card2csv() {
+ const cards = document.querySelectorAll("div.ref-card");
+ const csvText = [
+ "id,parent,type,label,url"
+ ];
+
+ const id = 0, parent=1, type=2, label=3, url=4;
+ for(const card of cards) {
+ const values = card.querySelectorAll("input");
+ const refNode = [
+ values[id].value,
+ values[parent].value,
+ values[type].value,
+ values[label].value,
+ values[url].value
+ ];
+
+ csvText.push(refNode.join(","));
+ }
+
+ return csvText.join("\n")
+}
\ No newline at end of file