/*********************************************************************************************************************
PRSM Participatory System Mapper
Copyright (C) 2022 Nigel Gilbert prsm@prsm.uk
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
This module provides import and export functions, to read and save map files in a variety of formats.
******************************************************************************************************************** */
import { Network, parseGephiNetwork, parseDOTNetwork } from "vis-network/peer"
import {
data,
doc,
room,
network,
container,
logHistory,
yUndoManager,
yNetMap,
ySamplesMap,
yPointsArray,
yHistory,
lastNodeSample,
lastLinkSample,
clearMap,
debug,
drawMinimap,
toggleDeleteButton,
undoRedoButtonStatus,
updateLastSamples,
setMapTitle,
setSideDrawer,
disableSideDrawerEditing,
doSnapToGrid,
setCurve,
setBackground,
setLegend,
setCluster,
sizing,
recreateClusteringMenu,
markMapSaved,
saveState,
fit,
yDrawingMap,
} from "./prsm.js"
import {
elem,
uuidv4,
deepMerge,
deepCopy,
splitText,
standardize_color,
rgbIsLight,
rgbToArray,
strip,
statusMsg,
alertMsg,
lowerFirstLetter,
stripNL,
} from "./utils.js"
import { styles } from "./samples.js"
import {
canvas,
refreshFromMap,
setUpBackground,
upgradeFromV1,
} from "./background.js"
import { refreshSampleNode, refreshSampleLink, updateLegend } from "./styles.js"
import Quill from "quill"
import { saveAs } from "file-saver"
//import * as quillToWord from 'quill-to-word' //dynamically loaded in exportNotes
import { read, writeFileXLSX, utils } from "xlsx"
import { compressToUTF16, decompressFromUTF16 } from "lz-string"
import { XMLParser } from "fast-xml-parser"
import { fabric } from "fabric"
import { version } from "../package.json"
const NODEWIDTH = 10 // chars for label splitting
var lastFileName = "" // the name of the file last read in
let msg = ""
/**
* Get the name of a map file to read and load it
* @param {event} e
*/
export function readSingleFile(e) {
var file = e.target.files[0]
if (!file) {
return
}
let fileName = file.name
lastFileName = fileName
document.body.style.cursor = "wait"
statusMsg("Reading '" + fileName + "'")
msg = ""
e.target.value = ""
var reader = new FileReader()
reader.onloadend = function (e) {
try {
document.body.style.cursor = "wait"
loadFile(e.target.result)
if (!msg) alertMsg("Read '" + fileName + "'", "info")
} catch (err) {
document.body.style.cursor = "default"
alertMsg("Error reading '" + fileName + "': " + err.message, "error")
console.log(err)
clearMap()
}
document.body.style.cursor = "default"
}
reader.readAsArrayBuffer(file)
}
export function openFile() {
elem("fileInput").click()
}
/**
* Allow user to open a file by dragging and dropping it over the PRSM window
*/
elem("container").addEventListener("drop", (e) => {
e.preventDefault()
let dt = e.dataTransfer
let files = dt.files
if (files.length > 0) {
readSingleFile({ target: { files: files } })
}
})
elem("container").addEventListener("dragover", (e) => {
e.preventDefault()
})
/**
* determine what kind of file it is, parse it and replace any current map with the one read from the file
* @param {string} contents - what is in the file
*/
function loadFile(contents) {
if (data.nodes.length > 0)
if (
!confirm(
"Loading a file will delete the current network. Are you sure you want to replace it?"
)
)
return
saveState()
// load the file as one single yjs transaction to reduce server traffic
clearMap()
doc.transact(() => {
switch (lastFileName.split(".").pop().toLowerCase()) {
case "csv":
loadCSV(arrayBufferToString(contents))
break
case "graphml":
loadGraphML(arrayBufferToString(contents))
break
case "gml":
loadGML(arrayBufferToString(contents))
break
case "json":
case "prsm":
loadPRSMfile(arrayBufferToString(contents))
break
case "gv":
case "dot":
loadDOTfile(arrayBufferToString(contents))
break
case "xlsx":
case "xls":
loadExcelfile(contents)
break
case "gexf":
loadGEXFfile(arrayBufferToString(contents))
break
default:
throw new Error("Unrecognised file name suffix")
}
let nodesToUpdate = []
data.nodes.get().forEach((n) => {
// ensure that all nodes have a grp property (converting 'group' property for old format files)
if (!n.grp) n.grp = n.group ? "group" + (n.group % 9) : "group0"
// reassign the sample properties to the nodes
n = deepMerge(styles.nodes[n.grp], n)
// version 1.6 made changes to label scaling
n.scaling = {
label: { enabled: false, max: 40, min: 10 },
max: 100,
min: 10,
}
nodesToUpdate.push(n)
})
data.nodes.update(nodesToUpdate)
// same for edges
let edgesToUpdate = []
data.edges.get().forEach((e) => {
// ensure that all edges have a grp property (converting 'group' property for old format files)
if (!e.grp) e.grp = e.group ? "edge" + (e.group % 9) : "edge0"
// reassign the sample properties to the edges
e = deepMerge(styles.edges[e.grp], e)
edgesToUpdate.push(e)
})
data.edges.update(edgesToUpdate)
fit()
updateLegend()
logHistory("loaded <" + lastFileName + ">")
})
yUndoManager.clear()
undoRedoButtonStatus()
toggleDeleteButton()
drawMinimap()
}
/**
* convert an ArrayBuffer to String
* @param {arrayBuffer} contents
* @returns string
*/
function arrayBufferToString(contents) {
let decoder = new TextDecoder("utf-8")
return decoder.decode(new DataView(contents))
}
/**
* Parse and load a PRSM map file, or a JSON file exported from Gephi
* @param {string} str
*/
function loadPRSMfile(str) {
if (str[0] != "{") str = decompressFromUTF16(str)
let json = JSON.parse(str)
if (json.version && version.substring(0, 3) > json.version.substring(0, 3)) {
alertMsg("Warning: file was created in an earlier version", "warn")
msg = "old version"
}
updateLastSamples(json.lastNodeSample, json.lastLinkSample)
if (json.buttons) setButtonStatus(json.buttons)
if (json.mapTitle) yNetMap.set("mapTitle", setMapTitle(json.mapTitle))
/* if (json.recentMaps) {
let recents = JSON.parse(localStorage.getItem('recents')) || {}
localStorage.setItem('recents', JSON.stringify(Object.assign(json.recentMaps, recents)))
} */
if (json.attributeTitles) yNetMap.set("attributeTitles", json.attributeTitles)
else yNetMap.set("attributeTitles", {})
if (json.edges.length > 0 && "source" in json.edges[0]) {
// the file is from Gephi and needs to be translated
let parsed = parseGephiNetwork(json, {
edges: {
inheritColors: false,
},
nodes: {
fixed: false,
parseColor: true,
},
})
data.nodes.add(parsed.nodes)
data.edges.add(parsed.edges)
} else {
json.nodes.forEach((n) => {
// at version 1.5, the title: property was renamed to note:
if (!n.note && n.title) n.note = n.title.replace(/<br>|<p>/g, "\n")
delete n.title
if (n.note && !(n.note instanceof Object))
n.note = { ops: [{ insert: n.note }] }
})
data.nodes.add(json.nodes)
json.edges.forEach((e) => {
if (!e.note && e.title) e.note = e.title.replace(/<br>|<p>/g, "\n")
delete e.title
if (e.note && !(e.note instanceof Object))
e.note = { ops: [{ insert: e.note }] }
})
data.edges.add(json.edges)
}
// before v1.4, the style array was called samples
if (json.samples) json.styles = json.samples
if (json.styles) {
styles.nodes = deepMerge(styles.nodes, json.styles.nodes)
for (let n in styles.nodes) {
delete styles.nodes[n].chosen
}
styles.edges = deepMerge(styles.edges, json.styles.edges)
for (let e in styles.edges) {
delete styles.edges[e].chosen
}
for (let groupId in styles.nodes) {
ySamplesMap.set(groupId, {
node: styles.nodes[groupId],
})
if (groupId.match(/\d+/)?.[0]) refreshSampleNode(groupId)
}
for (let edgeId in styles.edges) {
ySamplesMap.set(edgeId, {
edge: styles.edges[edgeId],
})
if (edgeId.match(/\d+/)?.[0]) refreshSampleLink(edgeId)
}
}
yDrawingMap.clear()
canvas.clear()
yPointsArray.delete(0, yPointsArray.length)
if (json.underlay) {
// background from v1; update it
yPointsArray.insert(0, json.underlay)
if (yPointsArray.length > 0) upgradeFromV1(yPointsArray.toArray())
}
if (json.background) {
setUpBackground()
let map = JSON.parse(json.background)
for (const [key, value] of Object.entries(map)) {
yDrawingMap.set(key, value)
}
refreshFromMap(Object.keys(map))
}
yHistory.delete(0, yHistory.length)
if (json.history) yHistory.insert(0, json.history)
if (json.description) {
yNetMap.set("mapDescription", json.description)
disableSideDrawerEditing()
setSideDrawer(json.description)
}
// node sizing has to be done after nodes have been created
sizing(yNetMap.get("sizing"))
}
/**
* parse and load a GraphViz (.DOT or .GV) file
* uses the vis-network DOT parser, which is pretty hopeless -
* e.g. it does not manage dotted or dashed node borders and
* requires some massaging of the parameters it does recognise
*
* @param {string} graph contents of DOT file
* @returns nodes and edges as an object
*/
function loadDOTfile(graph) {
let parsedData = parseDOTNetwork(graph)
data.nodes.add(
parsedData.nodes.map((node) => {
let n = strip(node, ["id", "label", "color", "shape", "font", "width"])
if (!n.id) n.id = uuidv4()
if (!n.color) n.color = deepCopy(styles.nodes["group0"].color)
if (!n.font) n.font = deepCopy(styles.nodes["group0"].font)
if (!n.shape) n.shape = deepCopy(styles.nodes["group0"].shape)
if (n.font?.size) n.font.size = parseInt(n.font.size)
if (n.width) n.borderWidth = parseInt(n.width)
if (n.shape === "plaintext") {
n.shape = "text"
n.borderWidth = 0
}
return n
})
)
data.edges.add(
parsedData.edges.map((edge) => {
let e = strip(edge, ["id", "from", "to", "label", "color", "dashes"])
if (!e.id) e.id = uuidv4()
if (!e.color) e.color = deepCopy(styles.edges["edge0"].color)
if (!e.dashes) e.dashes = deepCopy(styles.edges["edge0"].dashes)
if (!e.width)
e.width = e.width ? parseInt(e.width) : styles.edges["edge0"].width
return e
})
)
}
/**
* parse and load a graphML file
* @param {string} graphML
*/
function loadGraphML(graphML) {
let options = {
ignoreAttributes: false,
attributeNamePrefix: "",
alwaysCreateTextNode: false,
isArray: (name, jpath) => {
// Define which elements should always be arrays
const arrayPaths = [
"graphml.key",
"graphml.graph.node",
"graphml.graph.edge",
"graphml.graph.node.data",
"graphml.graph.edge.data",
]
return arrayPaths.includes(jpath)
},
textNodeName: "#text",
trimValues: true,
}
const parser = new XMLParser(options)
const parsedData = parser.parse(graphML)
if (parsedData.graphml && parsedData.graphml.graph) {
let attributeNames = {}
if (parsedData.graphml.key) {
parsedData.graphml.key.forEach((key) => {
attributeNames[key.id] = key["attr.name"]
})
}
const nodes = parsedData.graphml.graph.node.map((node) => {
const nodeData = {}
if (node.data) {
node.data.forEach((data) => {
nodeData[attributeNames[data["key"]]] = data["#text"]
})
}
return {
id: node["id"],
...nodeData,
}
})
const edges = parsedData.graphml.graph.edge.map((edge) => {
const edgeData = {}
if (edge.data) {
edge.data.forEach((data) => {
edgeData[attributeNames[data["key"]]] = data["#text"]
})
}
return {
id: edge["id"],
source: edge["source"],
target: edge["target"],
...edgeData,
}
})
let nodesToUpdate = []
nodes.forEach((node) => {
let n = deepCopy(styles.nodes.group0)
if (!node.id) throw new Error(`No ID for node ${node.label}`)
n.id = node.id
n.label = node.label ? node.label : node.id
if (node.size) n.size = node.size
if (node.x) n.x = node.x
if (node.y) n.y = node.y
if (node.r != undefined && node.g != undefined && node.b != undefined) {
n.color.background = `rgb(${node.r},${node.g},${node.b})`
n.font.color = rgbIsLight(node.r, node.g, node.b)
? "rgb(0,0,0)"
: "rgb(255,255,255)"
}
nodesToUpdate.push(n)
})
data.nodes.update(nodesToUpdate)
// and each edge
let edgesToUpdate = []
edges.forEach((edge) => {
let e = deepCopy(styles.edges.edge0)
if (!edge.id) throw new Error("Missing edge ID")
e.id = edge.id
if (!data.nodes.get(edge.source))
throw new Error(
`No node ${edge.source} for source of edge ID ${edge.id}`
)
e.from = edge.source
if (!data.nodes.get(edge.target))
throw new Error(
`No node ${edge.target} for source of edge ID ${edge.id}`
)
e.to = edge.target
e.width = edge.weight > 20 ? 20 : edge.weight < 1 ? 1 : edge.weight
edgesToUpdate.push(e)
})
data.edges.update(edgesToUpdate)
} else {
alertMsg("Bad format in GraphML file", "error")
throw new Error("Bad format in GraphML file")
}
}
/**
* Parse and load a Gephi GEXF file into PRSM format
* @param {string} gexf - XML string from file
*/
function loadGEXFfile(gexf) {
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: "",
processEntities: true,
isArray: (name) => ["node", "edge", "attribute", "attvalue"].includes(name),
transformTagName: (tag) => tag.startsWith("viz:") ? tag.replace("viz:", "viz_") : tag,
});
const jsonObj = parser.parse(gexf);
const graph = jsonObj.gexf?.graph || jsonObj.graph;
if (!graph) throw new Error("Invalid GEXF format: no graph found");
const attributes = processAttributes(graph.attributes);
const nodes = (graph.nodes?.node || []).map((node) => ({
id: node.id,
label: node.label || node.id,
attributes: processAttributeValues(node.attvalues?.attvalue || []),
...processVizAttributes(node),
...processNodePosition(node),
}));
const edges = (graph.edges?.edge || []).map((edge) => ({
id: edge.id,
source: edge.source,
target: edge.target,
type: edge.type || graph.defaultedgetype || "directed",
weight: parseFloat(edge.weight) || 1,
attributes: processAttributeValues(edge.attvalues?.attvalue || []),
...processEdgeVizAttributes(edge),
}));
const attributeNames = { ...yNetMap.get("attributeTitles") || {} };
Object.entries(attributes.nodes).forEach(([id, { title }]) => attributeNames[id] = title);
yNetMap.set("attributeTitles", attributeNames);
recreateClusteringMenu(attributeNames);
const nodesToUpdate = nodes.map((node) => {
if (!node.id) throw new Error(`No ID for node ${node.label}`);
const base = { ...deepCopy(styles.nodes.group0), ...node.attributes };
const color = node.viz?.color;
return {
...base,
id: node.id,
label: node.label,
x: node.position?.x,
y: node.position?.y,
size: node.viz?.size,
shape: node.viz?.shape,
color: color ? { ...base.color, background: rgba(color) } : base.color,
font: color ? { ...base.font, color: rgbIsLight(color.r, color.g, color.b) ? "rgb(0,0,0)" : "rgb(255,255,255)" } : base.font,
};
});
data.nodes.update(nodesToUpdate);
const edgesToUpdate = edges.map((edge) => {
if (!edge.id) throw new Error("Missing edge ID");
if (!data.nodes.get(edge.source)) throw new Error(`No node ${edge.source} for edge ${edge.id}`);
if (!data.nodes.get(edge.target)) throw new Error(`No node ${edge.target} for edge ${edge.id}`);
const color = edge.viz?.color;
const width = Math.min(20, Math.max(1, edge.weight));
return {
...deepCopy(styles.edges.edge0),
id: edge.id,
from: edge.source,
to: edge.target,
width,
color: color ? rgba(color) : "rgba(0,0,01)"
};
});
data.edges.update(edgesToUpdate);
// === Helpers ===
function processAttributes(attributesNode) {
const result = { nodes: {}, edges: {} };
if (!attributesNode) return result;
const attributes = Array.isArray(attributesNode) ? attributesNode : [attributesNode];
(attributes || []).forEach(({ class: cls, attribute = [] }) => {
attribute.forEach(({ id, title, name, type }) => {
result[cls === "node" ? "nodes" : "edges"][id] = {
title: title || name,
type: type || "string",
};
});
});
return result;
}
function processAttributeValues(attvalues) {
return (Array.isArray(attvalues) ? attvalues : []).reduce((acc, { for: key, value }) => {
if (key !== undefined && value !== undefined) acc[key] = value;
return acc;
}, {});
}
function processVizAttributes(el) {
const { viz_size, viz_color, viz_shape } = el;
const viz = {};
if (viz_size) viz.size = parseFloat(viz_size.value || viz_size.size);
if (viz_color) viz.color = {
r: parseInt(viz_color.r || 0),
g: parseInt(viz_color.g || 0),
b: parseInt(viz_color.b || 0),
a: parseFloat(viz_color.a ?? 1.0),
};
if (viz_shape) viz.shape = viz_shape.value;
return Object.keys(viz).length ? { viz } : {};
}
function processNodePosition({ viz_position, x, y, z }) {
const pos = viz_position
? { x: +viz_position.x || 0, y: +viz_position.y || 0, z: +viz_position.z || 0 }
: (x || y || z) ? { x: +x, y: +y, z: +z } : null;
return pos ? { position: pos } : {};
}
function processEdgeVizAttributes({ viz_thickness, viz_color, viz_shape }) {
const viz = {};
if (viz_thickness) viz.thickness = parseFloat(viz_thickness.value);
if (viz_color) viz.color = {
r: parseInt(viz_color.r || 0),
g: parseInt(viz_color.g || 0),
b: parseInt(viz_color.b || 0),
a: parseFloat(viz_color.a ?? 1.0),
};
if (viz_shape) viz.shape = viz_shape.value;
return Object.keys(viz).length ? { viz } : {};
}
function rgba({ r, g, b, a = 1.0 }) {
return `rgba(${r},${g},${b},${a})`;
}
}
/**
* Parse and load a GML file
* @param {string} gml
*/
function loadGML(gml) {
if (gml.search("graph") < 0) throw new Error("invalid GML format")
let tokens = gml.match(/"[^"]+"|[\w]+|\[|\]/g)
let node
let edge
let edgeId = 0
let tok = tokens.shift()
while (tok) {
switch (tok) {
case "graph":
break
case "node":
tokens.shift() // [
node = {}
tok = tokens.shift()
while (tok != "]") {
switch (tok) {
case "id":
node.id = tokens.shift().toString()
break
case "label":
node.label = splitText(
tokens.shift().replace(/"/g, ""),
NODEWIDTH
)
break
case "color":
case "colour":
node.color = {}
node.color.background = tokens.shift().replace(/"/g, "")
break
case "[": // skip embedded groups
while (tok != "]") tok = tokens.shift()
break
default:
break
}
tok = tokens.shift() // ]
}
if (node.label == undefined) node.label = node.id
data.nodes.add(node)
break
case "edge":
tokens.shift() // [
edge = {}
tok = tokens.shift()
while (tok != "]") {
switch (tok) {
case "id":
edge.id = tokens.shift().toString()
break
case "source":
edge.from = tokens.shift().toString()
break
case "target":
edge.to = tokens.shift().toString()
break
case "label":
edge.label = tokens.shift().replace(/"/g, "")
break
case "color":
case "colour":
edge.color = tokens.shift().replace(/"/g, "")
break
case "[": // skip embedded groups
while (tok != "]") tok = tokens.shift()
break
default:
break
}
tok = tokens.shift() // ]
}
if (edge.id == undefined) edge.id = (edgeId++).toString()
data.edges.add(edge)
break
default:
break
}
tok = tokens.shift()
}
}
/**
* Read a comma separated values file consisting of 'From' label and 'to' label, on each row,
with a header row (ignored)
optional, cols 3 and 4 can include the groups (styles) of the from and to nodes,
column 5 can include the style of the edge. All these must be integers between 1 and 9
* @param {string} csv
*/
function loadCSV(csv) {
let lines = csv.split(/\r\n|\n/)
let labels = new Map()
let links = []
for (let i = 1; i < lines.length; i++) {
if (lines[i].length <= 2) continue // empty line
let line = splitCSVrow(lines[i])
let from = node(line[0], line[2], i)
let to = node(line[1], line[3], i)
let grp = line[4]
if (grp) grp = "edge" + (parseInt(grp.trim()) - 1)
links.push({
id: uuidv4(),
from: from.id,
to: to.id,
grp: grp,
})
}
data.nodes.add(Array.from(labels.values()))
data.edges.add(links)
/**
* Parse a CSV row, accounting for commas inside quotes
* @param {string} row
* @returns array of fields
*/
function splitCSVrow(row) {
let insideQuote = false,
entries = [],
entry = []
row.split("").forEach(function (character) {
if (character === '"') {
insideQuote = !insideQuote
} else {
if (character == "," && !insideQuote) {
entries.push(entry.join(""))
entry = []
} else {
entry.push(character)
}
}
})
entries.push(entry.join(""))
return entries
}
/**
* retrieves or creates a (new) node object with given label and style,
* @param {string} label
* @param {number} grp
* @param {number} lineNo the file line where this node was read from
* @returns the node object
*/
function node(label, grp, lineNo) {
label = label.trim()
if (grp) {
let styleNo = parseInt(grp)
if (isNaN(styleNo) || styleNo < 1 || styleNo > 9) {
throw new Error(
`Line ${lineNo}: Columns 3 and 4 must be values between 1 and 9 or blank (found ${grp})`
)
}
grp = "group" + (styleNo - 1)
}
if (labels.get(label) == undefined) {
labels.set(label, { id: uuidv4(), label: label.toString(), grp: grp })
}
return labels.get(label)
}
}
/**
* Reads map data from an Excel file. The file must have two spreadsheets in the workbook, named Factors and Links
* In the spreadsheet, there must be a header row, and columns for (minimally) Label (and for links, also From and To,
* with entries with the exact same text as the Labels in the Factor sheet. There may be a Style column, which is used
* to specify the style for the Factor or Link (numbered from 1 to 9). There may be a Description (or note or Note)
* column, the contents of which are treated as a Factor or Link note. Any other columns are treated as holding values
* for additional Attributes.
*
* Uses https://sheetjs.com/
*
* @param {*} contents
* @returns nodes and edges data
*/
function loadExcelfile(contents) {
let workbook = read(contents)
let factorsSS = workbook.Sheets["Factors"]
if (!factorsSS) throw new Error("Sheet named Factors not found in Workbook")
let linksSS = workbook.Sheets["Links"]
if (!linksSS) throw new Error("Sheet named Links not found in Workbook")
// attributeNames is an object with properties attributeField: attributeTitle
let attributeNames = {}
/*
Transform data about factors into an array of objects, with properties named after the column headings
(with first letter lower cased if necessary) and values from that row's cells.
add a GUID to the object, change 'Style' property to 'grp'
Style is a style number
Put value of Description or Notes property into notes
Check that any other property names are not in the list of known attribute names; if so add that property name to the attribute name list
Place the factor either at the given x and y coordinates or at some random location
*/
// convert data from Factors sheet into an array of objects with properties starting with lower case letters
let factors = utils
.sheet_to_json(factorsSS)
.map((f) => lowerInitialLetterOfProps(f))
let maxIndexOfFactorStyles = Object.keys(styles.nodes).length - 1
factors.forEach((f) => {
f.id = uuidv4()
if (f.style) {
let styleNo = parseInt(f.style)
if (isNaN(styleNo) || styleNo < 1 || styleNo > maxIndexOfFactorStyles) {
throw new Error(
`Factors - Line ${f.__rowNum__}: Style must be a number between 1 and ${maxIndexOfFactorStyles} or blank (found ${f.style})`
)
}
f.grp = "group" + (styleNo - 1)
if (f.groupLabel) {
let styleDataSet = Array.from(
document.getElementsByClassName("sampleNode")
)[styleNo - 1].dataSet
let styleNode = styleDataSet.get("1")
styleNode.label = f.groupLabel
styleNode.groupLabel = f.groupLabel
styleDataSet.update(styleNode)
styles.nodes[f.grp].groupLabel = f.groupLabel
}
delete f.style
}
if (!f.label)
throw new Error(
`Factors - Line ${f.__rowNum__}: Factor does not have a Label`
)
let note = f.description || f.note
if (note) {
f.note = { ops: [{ insert: note + "\n" }] }
delete f.description
}
if (f.creator) {
f.created = {
time: f.createdTime ? Date.parse(f.createdTime) : Date.now(),
user: f.creator,
}
delete f.createdTime
}
if (f.modifier) {
f.modified = {
time: f.modifiedTime ? Date.parse(f.modifiedTime) : Date.now(),
user: f.modifier,
}
delete f.modifiedTime
}
// filter out known properties, leaving the rest to become attributes
Object.keys(f)
.filter(
(k) =>
![
"id",
"grp",
"label",
"groupLabel",
"shape",
"note",
"created",
"createdTime",
"creator",
"modified",
"modifiedTime",
"modifier",
"x",
"y",
"__rowNum__",
].includes(k)
)
.forEach((k) => {
let attributeField = Object.keys(attributeNames).find(
(prop) => attributeNames[prop] === k
)
if (!attributeField) {
// not found, so add
attributeField = "att" + (Object.keys(attributeNames).length + 1)
attributeNames[attributeField] = k
}
f[attributeField] = f[k]
delete f[k]
})
f.x = parseInt(f.x)
if (!f.x || isNaN(f.x)) f.x = Math.random() * 500
f.y = parseInt(f.y)
if (!f.y || isNaN(f.y)) f.y = Math.random() * 500
})
/* for each row of links
add a GUID
look up from and to in factor objects and replace with their ids
add other attributes as for factors */
let links = utils
.sheet_to_json(linksSS)
.map((l) => lowerInitialLetterOfProps(l))
links.forEach((l) => {
l.id = uuidv4()
if (l.style) {
let styleNo = parseInt(l.style)
if (isNaN(styleNo) || styleNo < 1 || styleNo > 9) {
throw new Error(
`Links - Line ${l.__rowNum__}: Style must be a number between 1 and 9, a style name, or blank (found ${l.style})`
)
}
l.grp = "edge" + (styleNo - 1)
if (l.groupLabel) {
let styleDataSet = Array.from(
document.getElementsByClassName("sampleLink")
)[styleNo - 1].dataSet
let styleEdge = styleDataSet.get("1")
styleEdge.label = l.groupLabel
styleEdge.groupLabel = l.groupLabel
styleDataSet.update(styleEdge)
styles.edges[l.grp].groupLabel = l.groupLabel
}
delete l.style
}
if (l.creator) {
l.created = {
time: l.createdTime ? Date.parse(l.createdTime) : Date.now(),
user: l.creator,
}
delete l.createdTime
}
if (l.modifier) {
l.modified = {
time: l.modifiedTime ? Date.parse(l.modifiedTime) : Date.now(),
user: l.modifier,
}
delete l.modifiedTime
}
let fromFactor = factors.find((factor) => factor.label === l.from)
if (fromFactor) l.from = fromFactor.id
else
throw new Error(
`Links - Line ${l.__rowNum__}: From factor (${l.from}) not found for link`
)
let toFactor = factors.find((factor) => factor.label === l.to)
if (toFactor) l.to = toFactor.id
else
throw new Error(
`Links - Line ${l.__rowNum__}: To factor (${l.to}) not found for link`
)
let note = l.description || l.note
if (note) {
l.note = { ops: [{ insert: note + "\n" }] }
delete l.description
}
Object.keys(l)
.filter(
(k) =>
![
"id",
"from",
"to",
"grp",
"groupLabel",
"creator",
"created",
"label",
"modified",
"modifier",
"note",
"__rowNum__",
].includes(k)
)
.forEach((k) => {
let attributeField = Object.keys(attributeNames).find(
(prop) => attributeNames[prop] === k
)
if (!attributeField) {
// not found, so add
attributeField = "att" + (Object.keys(attributeNames).length + 1)
attributeNames[attributeField] = k
}
l[attributeField] = l[k]
delete l[k]
})
})
factors.forEach((f) => {
f.label = splitText(f.label, NODEWIDTH)
})
data.nodes.add(factors)
data.edges.add(links)
yNetMap.set("attributeTitles", attributeNames)
recreateClusteringMenu(attributeNames)
/**
* ensure the initial letter of each property of obj is lower case
* @param {object} obj
* @returns copy of object
*/
function lowerInitialLetterOfProps(obj) {
return Object.fromEntries(
Object.entries(obj).map(([k, v]) => [lowerFirstLetter(k), v])
)
}
}
/**
* save the current map as a PRSM file (in JSON format)
*/
export function savePRSMfile() {
network.storePositions()
let attributes = yNetMap.get("attributeTitles") || []
let nodeFields = [
"id",
"label",
"grp",
"x",
"y",
"color",
"font",
"borderWidth",
"shape",
"shapeProperties",
"margin",
"thumbUp",
"thumbDown",
"created",
"modified",
]
let json = JSON.stringify(
{
saved: new Date(Date.now()).toLocaleString(),
version: version,
room: room,
mapTitle: elem("maptitle").innerText,
// security risk to save recent maps to a file
// recentMaps: JSON.parse(localStorage.getItem('recents')),
lastNodeSample: lastNodeSample,
lastLinkSample: lastLinkSample,
// clustering, and up/down, paths between and x links away settings are not saved (and hidden property is not saved)
buttons: getButtonStatus(),
attributeTitles: yNetMap.get("attributeTitles"),
styles: styles,
nodes: data.nodes.get({
fields: [...nodeFields, ...Object.keys(attributes)],
filter: (n) => !n.isCluster,
}),
edges: data.edges.get({
fields: [
"id",
"arrows",
"color",
"created",
"dashes",
"font",
"from",
"grp",
"label",
"modified",
"note",
"to",
"width",
],
filter: (e) => !e.isClusterEdge,
}),
background: JSON.stringify(yDrawingMap.toJSON()),
history: yHistory.map((s) => {
s.state = null
return s
}),
description: yNetMap.get("mapDescription"),
},
null,
"\t"
)
if (!/plain/.test(debug)) json = compressToUTF16(json)
saveStr(json, "prsm")
markMapSaved()
}
/**
* return an object with the current Network panel setting for saving
* settings for link radius and up/down stream are not saved
* @return an object with the Network panel settings
*/
function getButtonStatus() {
return {
snapToGrid: elem("snaptogridswitch").checked,
curve: elem("curveSelect").value,
background: elem("netBackColorWell").style.backgroundColor,
legend: elem("showLegendSwitch").checked,
sizing: elem("sizing").value,
}
}
/**
* Set the Network panel buttons to their values loaded from a file
* @param {Object} settings
*/
function setButtonStatus(settings) {
yNetMap.set("snapToGrid", settings.snapToGrid)
doSnapToGrid(settings.snapToGrid)
yNetMap.set("curve", settings.curve)
setCurve(settings.curve)
yNetMap.set("background", settings.background || "#ffffff")
setBackground(yNetMap.get("background"))
yNetMap.set("legend", settings.legend)
setLegend(settings.legend)
yNetMap.set("sizing", settings.sizing)
// sizing done after the nodes have been created: sizing(settings.sizing)
yNetMap.set("radius", { radiusSetting: "All", selected: [] })
yNetMap.set("stream", { streamSetting: "All", selected: [] })
yNetMap.set("paths", { pathsSetting: "All", selected: [] })
yNetMap.set("cluster", "none")
setCluster("none")
}
/**
* Save the string to a local file
* @param {string} str file contents
* @param {string} extn file extension
*
* Browser will only ask for name and location of the file to be saved if
* it has a user setting to do so. Otherwise, it is saved at a default
* download location with a default name.
*/
function saveStr(str, extn) {
setFileName(extn)
const blob = new Blob([str], { type: "text/plain;charset=utf-8" })
saveAs(blob, lastFileName, { autoBom: true })
}
/**
* save the map as a PNG image file
*/
const maxScale = 5 // max upscaling for image (avoids blowing up very small networks excessively)
export function exportPNGfile() {
setFileName("png")
// create a very large canvas, so we can download at high resolution
network.storePositions()
// first, create a large offscreen div to hold a copy of the network at the required width
const bigWidth = 4096 / window.devicePixelRatio // half the number of pixels in the image file (also half the height, as the image is square)
const bigMargin = 256 / window.devicePixelRatio // white space around network so not too close to printable edge
let bigNetDiv = document.createElement("div")
bigNetDiv.id = "big-net-pane"
bigNetDiv.style.position = "absolute"
bigNetDiv.style.top = "-9999px"
bigNetDiv.style.left = "-9999px"
bigNetDiv.style.width = `${bigWidth}px`
bigNetDiv.style.height = `${bigWidth}px`
elem("main").appendChild(bigNetDiv)
// create an offscreen canvas of the same size to apply the background to
let bigBackgroundCanvas = new OffscreenCanvas(bigWidth, bigWidth)
bigBackgroundCanvas.id = "big-background-canvas"
let bigFabricCanvas = new fabric.StaticCanvas("big-background-canvas", {
width: bigWidth,
height: bigWidth,
})
// make a network with the same nodes and links as the original map
let bigNetwork = new Network(bigNetDiv, data, {
physics: { enabled: false },
edges: {
smooth: {
enabled: elem("curveSelect").value === "Curved",
type: "cubicBezier",
},
},
})
bigNetwork.on("afterDrawing", (bigNetContext) => {
// copy the background objects to the big fabric canvas
bigFabricCanvas.loadFromJSON(JSON.stringify(canvas), () => {
// adjust the fabric canvas scale and center to match the big network and match the background colour
bigFabricCanvas.setZoom(bigNetwork.getScale())
let fcCenter = bigFabricCanvas.getVpCenter()
bigFabricCanvas.relativePan({
x: bigNetwork.getScale() * (fcCenter.x - center.x),
y: bigNetwork.getScale() * (fcCenter.y - center.y),
})
bigFabricCanvas.setBackgroundColor(
elem("underlay").style.backgroundColor || "rgb(255, 255, 255)"
)
bigFabricCanvas.requestRenderAll()
// create an image version of the background and copy it onto the big network canvas
let bigBackgroundImage = document.createElement("img")
bigBackgroundImage.onload = function () {
bigNetContext.globalCompositeOperation = "destination-over"
bigNetContext.drawImage(bigBackgroundImage, 0, 0, bigWidth, bigWidth)
// save the canvas to a file
bigNetContext.canvas.toBlob((blob) => saveAs(blob, lastFileName))
// clean up
bigNetwork.destroy()
bigNetDiv.remove()
bigFabricCanvas.dispose()
}
bigBackgroundImage.src = bigFabricCanvas.toDataURL()
})
})
let box = mapBoundingBox(network, canvas, network.getSelectedNodes())
let scale =
network.getScale() *
Math.min(
(bigWidth - bigMargin) / (box.right - box.left),
(bigWidth - bigMargin) / (box.bottom - box.top)
)
if (scale > maxScale) scale = maxScale
let center = network.DOMtoCanvas({
x: 0.5 * (box.right + box.left),
y: 0.5 * (box.bottom + box.top),
})
bigNetwork.moveTo({
scale: scale,
position: center,
})
/**
* Get a bounding box for everything on the map (the nodes and the background objects)
* @param {object} ntwk the map on which the nodes are placed
* @param {array} selectedNodes Ids of selected nodes, if any
* @returns box as an object, with dimensions in DOM coords
*/
function mapBoundingBox(ntwk, fabCanvas, selectedNodes = []) {
let top = Infinity,
bottom = -Infinity,
left = Infinity,
right = -Infinity
// use all nodes if none selected
if (selectedNodes.length === 0) selectedNodes = data.nodes.map((n) => n.id)
selectedNodes.forEach((nodeId) => {
let canvasBB = ntwk.getBoundingBox(nodeId)
let tl = ntwk.canvasToDOM({ x: canvasBB.left, y: canvasBB.top })
let br = ntwk.canvasToDOM({ x: canvasBB.right, y: canvasBB.bottom })
if (left > tl.x) left = tl.x
if (right < br.x) right = br.x
if (top > tl.y) top = tl.y
if (bottom < br.y) bottom = br.y
})
// only include background objects if no nodes are selected
if (selectedNodes.length === 0) {
fabCanvas.forEachObject((obj) => {
let boundingBox = obj.getBoundingRect()
console.log(obj, boundingBox)
if (left > boundingBox.left) left = boundingBox.left
if (right < boundingBox.left + boundingBox.width)
right = boundingBox.left + boundingBox.width
if (top > boundingBox.top) top = boundingBox.top
if (bottom < boundingBox.top + boundingBox.height)
bottom = boundingBox.top + boundingBox.height
})
}
if (left === Infinity) {
top = bottom = left = right = 0
}
return { left: left, right: right, top: top, bottom: bottom }
}
}
/**
* save a local file containing all the node and edge notes, plus the map description, as a Word document
*/
export async function exportNotes() {
let delta = { ops: [{ insert: "\n" }] }
// start with the title of the map if there is one
let title = elem("maptitle").innerText
if (title !== "Untitled map") {
delta = {
ops: [{ insert: title }, { attributes: { header: 1 }, insert: "\n" }],
}
}
// get contents of map note if there is one
if (yNetMap.get("mapDescription")) {
delta.ops = delta.ops.concat(
[
{ insert: "Description of the map" },
{ attributes: { header: 2 }, insert: "\n" },
],
yNetMap.get("mapDescription").text.ops
)
}
// add notes for factors
data.nodes
.get()
.toSorted((a, b) => a.label.localeCompare(b.label))
.forEach((n) => {
delta.ops = delta.ops.concat(
[
{ insert: `Factor: ${stripNL(n.label)}` },
{ attributes: { header: 2 }, insert: "\n" },
],
n.note ? n.note.ops : [{ insert: "[No note]\n" }]
)
})
// add notes for links
data.edges.forEach((e) => {
let heading = `Link from '${stripNL(data.nodes.get(e.from).label)}' to '${stripNL(data.nodes.get(e.to).label)}'`
delta.ops = delta.ops.concat([
{ insert: heading },
{ attributes: { header: 2 }, insert: "\n" },
])
delta.ops = delta.ops.concat(
e.note ? e.note.ops : [{ insert: "[No note]\n" }]
)
})
// save the delta as a Word file
const quillToWordConfig = {
exportAs: "blob",
paragraphStyles: {
normal: {
paragraph: {
spacing: {
line: 240,
},
},
},
},
}
const ql = await import("quill-to-word")
const docAsBlob = await ql.generateWord(delta, quillToWordConfig)
setFileName("docx")
saveAs(docAsBlob, lastFileName)
}
/**
* resets lastFileName to a munged version of the map title, with the supplied extension
* if lastFileName is null, uses the map title, or if no map title, 'network' as the filename
* @param {string} extn filename extension to apply
*/
export function setFileName(extn = "prsm") {
let title = elem("maptitle").innerText
if (title === "Untitled map") lastFileName = "network"
else
lastFileName = title.replace(/\s+/g, "").replaceAll(".", "_").toLowerCase()
lastFileName += "." + extn
}
/**
* Save the map as CSV files, one for nodes and one for edges
* Only node and edge labels and style ids are saved
*
* Now obsolete, as the Excel file format is much more useful
*/
/* export function exportCVS() {
let dummyDiv = document.createElement('div')
dummyDiv.id = 'dummy-div'
dummyDiv.style.display = 'none'
container.appendChild(dummyDiv)
let qed = new Quill('#dummy-div')
let str = 'Id,Label,Style,Note\n'
for (let node of data.nodes.get()) {
str += node.id + ','
if (node.label) str += '"' + node.label.replaceAll('\n', ' ') + '"'
str += ',' + node.grp + ','
if (node.note) {
qed.setContents(node.note)
// convert Quill formatted note to HTML, escaping all "
str +=
'"' +
new QuillDeltaToHtmlConverter(qed.getContents().ops, {
inlineStyles: true,
})
.convert()
.replaceAll('"', '""') +
'"'
}
str += '\n'
}
saveStr(str, 'nodes.csv')
str = 'Source,Target,Type,Id,Label,Style,Note\n'
for (let edge of data.edges.get()) {
str += edge.from + ','
str += edge.to + ','
str += 'directed,'
str += edge.id + ','
if (edge.label) str += edge.label.replaceAll('\n', ' ') + '"'
str += ',' + edge.grp + ','
if (edge.note) {
qed.setContents(edge.note)
// convert Quill formatted note to HTML, escaping all "
str +=
'"' +
new QuillDeltaToHtmlConverter(qed.getContents().ops, {
inlineStyles: true,
})
.convert()
.replaceAll('"', '""') +
'"'
}
str += '\n'
}
saveStr(str, 'edges.csv')
dummyDiv.remove()
} */
/**
* Save the map in an Excel workbook, with two sheets: Factors and Links
*/
export function exportExcel() {
// set up Quill note conversion
let dummyDiv = document.createElement("div")
dummyDiv.id = "dummy-div"
dummyDiv.style.display = "none"
container.appendChild(dummyDiv)
let qed = new Quill("#dummy-div")
// create workbook
const workbook = utils.book_new()
// Factors
let rows = data.nodes
.get()
.filter((n) => !n.isCluster)
.map((n) => {
if (n.created) {
n.creator = n.created.user
n.createdTime = new Date(n.created.time).toISOString()
}
if (n.modified) {
n.modifier = n.modified.user
n.modifiedTime = new Date(n.modified.time).toISOString()
}
n.style = parseInt(n.grp.substring(5)) + 1
if (n.note) n.Note = quillToText(n.note)
// don't save any of the listed properties
return omit(n, [
"bc",
"borderWidth",
"borderWidthSelected",
"color",
"created",
"fixed",
"font",
"grp",
"hidden",
"id",
"clusteredIn",
"level",
"labelHighlightBold",
"locked",
"margin",
"modified",
"nodeHidden",
"opacity",
"oldFont",
"oldFontColor",
"oldLabel",
"note",
"scaling",
"shadow",
"shapeProperties",
"size",
"heightConstraint",
"val",
"value",
"wasFixed",
"widthConstraint",
])
})
let factorWorksheet = utils.json_to_sheet(rows)
utils.book_append_sheet(workbook, factorWorksheet, "Factors")
// Links
let edges = deepCopy(data.edges.get().filter((e) => !e.isClusterEdge))
rows = edges.map((e) => {
if (e.created) {
e.creator = e.created.user
e.createdTime = new Date(e.created.time).toISOString()
}
if (e.modified) {
e.modifier = e.modified.user
e.modifiedTime = new Date(e.modified.time).toISOString()
}
e.style = parseInt(e.grp.substring(4)) + 1
e.from = data.nodes.get(e.from).label
e.to = data.nodes.get(e.to).label
if (e.note) e.Note = quillToText(e.note)
return omit(e, [
"arrows",
"color",
"created",
"dashes",
"font",
"grp",
"hoverWidth",
"id",
"note",
"selectionWidth",
"width",
])
})
let linksWorksheet = utils.json_to_sheet(rows)
utils.book_append_sheet(workbook, linksWorksheet, "Links")
setFileName("xlsx")
writeFileXLSX(workbook, lastFileName)
dummyDiv.remove()
function omit(obj, props) {
return Object.keys(obj)
.filter((key) => props.indexOf(key) < 0)
.reduce((obj2, key) => ((obj2[key] = obj[key]), obj2), {})
}
/**
*
* @param {object} ops
* @returns contents of Quill note as plain text
*/
function quillToText(ops) {
qed.setContents(ops)
// use qed.root.innerHTML to convert to HTML if that is preferred
return qed.getText()
}
}
/**
* Save the map as a GML file
* See https://web.archive.org/web/20190303094704/http://www.fim.uni-passau.de:80/fileadmin/files/lehrstuhl/brandenburg/projekte/gml/gml-technical-report.pdf for the format
*/
export function exportGML() {
let str =
'Creator "prsm ' +
version +
" on " +
new Date(Date.now()).toLocaleString() +
'"\ngraph\n[\n\tdirected 1\n'
let nodeIds = data.nodes.map((n) => n.id) //use integers, not GUIDs for node ids
for (let node of data.nodes.get()) {
str += "\tnode\n\t[\n\t\tid " + nodeIds.indexOf(node.id)
if (node.label) str += '\n\t\tlabel "' + node.label.replace(/"/g, "'") + '"'
let color = node.color.background || styles.nodes.group0.color.background
str += '\n\t\tcolor "' + color + '"'
str += "\n\t]\n"
}
for (let edge of data.edges.get()) {
str += "\tedge\n\t[\n\t\tsource " + nodeIds.indexOf(edge.from)
str += "\n\t\ttarget " + nodeIds.indexOf(edge.to)
if (edge.label) str += '\n\t\tlabel "' + edge.label + '"'
let color = edge.color.color || styles.edges.edge0.color.color
str += '\n\t\tcolor "' + color + '"'
str += "\n\t]\n"
}
str += "\n]"
saveStr(str, "gml")
}
/**
* Save the map as GraphViz file
* See https://graphviz.org/doc/info/lang.html
*/
export function exportDOT() {
let str = `/* Creator PRSM ${version} on ${new Date(Date.now()).toLocaleString()} */\ndigraph {\n`
for (let node of data.nodes.get()) {
str += `"${node.id}" [label="${node.label}",
color="${standardize_color(node.color.border)}", fillcolor="${standardize_color(node.color.background)}",
shape="${node.shape == "text" ? "plaintext" : node.shape}",
${gvNodeStyle(node)},
fontsize="${node.font.size}", fontcolor="${standardize_color(node.font.color)}"]\n`
}
for (let edge of data.edges.get()) {
str += `"${edge.from}" -> "${edge.to}" [label="${edge.label || ""}",
color="${standardize_color(edge.color.color)}"
style="${gvConvertEdgeStyle(edge)}"]\n`
}
str += "}\n"
saveStr(str, "gv")
function gvNodeStyle(node) {
let bDashes = node.shapeProperties.borderDashes
let val = 'style="filled'
if (Array.isArray(bDashes)) val += ", dotted"
else val += `, ${bDashes ? "dashed" : "solid"}`
val += `", penwidth="${node.borderWidth}"`
return val
}
function gvConvertEdgeStyle(edge) {
let bDashes = edge.dashes
let val = "solid"
if (Array.isArray(bDashes)) {
if (bDashes[0] == 10) val = "dashed"
else val = "dotted"
}
return val
}
}
/*
* Save the map as a GraphML file
* See http://graphml.graphdrawing.org/primer/graphml-primer.html
* GraphML is an XML-based file format for graphs. It is a W3C standard and is used by many graph visualization tools.
* The GraphML format is a simple and flexible way to represent graphs, including nodes, edges, and their attributes.
* It is widely used in graph visualization and analysis tools, such as Gephi, Cytoscape, and Graphviz.
* GraphML files can be easily generated and parsed by various programming languages and libraries, making it a popular choice for graph data exchange.
* The GraphML format is based on XML, which means it is human-readable and can be easily edited with a text editor.
* GraphML files can be used to represent directed and undirected graphs, as well as weighted and unweighted edges.
* GraphML files can also include metadata, such as node and edge labels, colors, and styles.
*/
export function exportGraphML() {
let str = `<?xml version="1.0" encoding="UTF-8"?>
<graphml xmlns="http://graphml.graphdrawing.org/xmlns"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://graphml.graphdrawing.org/xmlns/1.0/graphml.xsd">
<key id="d0" for="node" attr.name="label" attr.type="string"/>
<key id="d1" for="node" attr.name="style" attr.type="int"/>
<key id="d2" for="node" attr.name="color" attr.type="string"/>
<key id="d3" for="node" attr.name="shape" attr.type="string"/>
<key id="d4" for="node" attr.name="x" attr.type="float"/>
<key id="d5" for="node" attr.name="y" attr.type="float"/>
<key id="d6" for="node" attr.name="r" attr.type="int"/>
<key id="d7" for="node" attr.name="g" attr.type="int"/>
<key id="d8" for="node" attr.name="b" attr.type="int"/>
<key id="d9" for="node" attr.name="size" attr.type="int"/>
<key id="d10" for="edge" attr.name="label" attr.type="string"/>
<key id="d11" for="edge" attr.name="style" attr.type="int"/>
<key id="d12" for="edge" attr.name="color" attr.type="string"/>
<key id="d13" for="edge" attr.name="r" attr.type="int"/>
<key id="d14" for="edge" attr.name="g" attr.type="int"/>
<key id="d15" for="edge" attr.name="b" attr.type="int"/>
<key id="d16" for="edge" attr.name="weight" attr.type="int"/>
<graph id="${elem("maptitle").innerText}" edgedefault="directed">`
for (let node of data.nodes.get()) {
let color = node.color.background || styles.nodes.group0.color.background
let rgb = rgbToArray(color)
str += `
<node id="${node.id}">
<data key="d0">${node.label}</data>
<data key="d1">${parseInt(node.grp.substring(5)) + 1}</data>
<data key="d2">${color}</data>
<data key="d3">${node.shape}</data>
<data key="d4">${node.x}</data>
<data key="d5">${node.y}</data>
<data key="d6">${rgb[0]}</data>
<data key="d7">${rgb[1]}</data>
<data key="d8">${rgb[2]}</data>
<data key="d9">${node.size}</data>
</node>`
}
for (let edge of data.edges.get()) {
let color = edge.color.color || styles.edges.edge0.color.background
let rgb = rgbToArray(color)
str += `
<edge id="${edge.id}" source="${edge.from}" target="${edge.to}">
<data key="d10">${edge.label || ""}</data>
<data key="d11">${parseInt(edge.grp.substring(4)) + 1}</data>
<data key="d12">${color}</data>
<data key="d13">${rgb[0]}</data>
<data key="d14">${rgb[1]}</data>
<data key="d15">${rgb[2]}</data>
<data key="d16">${edge.width}</data>
</edge>`
}
str += `
</graph>
</graphml>`
saveStr(str, "graphml")
}
/**
* Save the map as a GEXF format file, for input to Gephi etc.
* See https://gexf.net/index.html
*/
export function exportGEXF() {
let str = `<?xml version='1.0' encoding='UTF-8'?>
<gexf xmlns="http://gexf.net/1.3" version="1.3" xmlns:viz="http://gexf.net/1.3/viz" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://gexf.net/1.3 http://gexf.net/1.3/gexf.xsd">
<meta lastmodifieddate="${new Date(Date.now()).toISOString().slice(0, 10)}">
<creator>PRSM ${version}</creator>
<title>${elem("maptitle").innerText}</title>
<description>Generated from ${window.location.href}</description>
</meta>
<graph defaultedgetype="directed" mode="static">`
let attributeNames = yNetMap.get("attributeTitles") || {}
if (attributeNames) {
str += `
<attributes class="node" mode="static">
`
Object.keys(attributeNames).forEach((attr) => {
str += ` <attribute id="${attr}" title="${attributeNames[attr]}" type="string"/>
`
})
str += ` </attributes>`
}
str += `
<nodes>`
data.nodes.forEach((node) => {
str += `
<node id="${node.id}"
label="${node.label}">`
if (attributeNames) {
str += `
<attvalues>`
Object.keys(attributeNames).forEach((attr) => {
if (node[attr]) str += `
<attvalue for="${attr}" value="${node[attr]}"/>`
})
str += `
</attvalues>`
}
str += `
<viz:size value="${node.size}"/>
<viz:position x="${node.x}" y="${node.y}"/>
</node>`
})
str += `
</nodes>
<edges>
`
data.edges.forEach((edge) => {
str += `
<edge id="${edge.id}"
source="${edge.from}"
target="${edge.to}"/>
`
})
str += `
</edges>
</graph>
</gexf>`
saveStr(str, "gexf")
}