/*********************************************************************************************************************
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,
refreshSampleNodes,
refreshSampleLinks,
setMapTitle,
setSideDrawer,
doSnapToGrid,
setCurve,
setBackground,
setLegend,
sizing,
recreateClusteringMenu,
markMapSaved,
saveState,
fit,
yDrawingMap,
} from './prsm.js'
import {
elem,
uuidv4,
deepMerge,
deepCopy,
splitText,
standardize_color,
strip,
statusMsg,
alertMsg,
lowerFirstLetter,
stripNL,
} from './utils.js'
import {styles} from './samples.js'
import {canvas, refreshFromMap, setUpBackground, upgradeFromV1} from './background.js'
import {updateLegend} from './styles.js'
import Quill from 'quill'
import {saveAs} from 'file-saver'
import * as quillToWord from 'quill-to-word'
import {read, writeFileXLSX, utils} from 'xlsx'
import {compressToUTF16, decompressFromUTF16} from 'lz-string'
import * as parser 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)
return
}
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':
loadExcelfile(contents)
break
default:
throw {message: '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
}
refreshSampleNodes()
refreshSampleLinks()
for (let groupId in styles.nodes) {
ySamplesMap.set(groupId, {
node: styles.nodes[groupId],
})
}
for (let edgeId in styles.edges) {
ySamplesMap.set(edgeId, {
edge: styles.edges[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)
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 = {
attributeNamePrefix: '',
attrNodeName: 'attr',
textNodeName: 'txt',
ignoreAttributes: false,
ignoreNameSpace: true,
allowBooleanAttributes: false,
parseNodeValue: true,
parseAttributeValue: true,
trimValues: true,
parseTrueNumberOnly: false,
arrayMode: false, //"strict"
}
var result = parser.validate(graphML, options)
if (result !== true) {
throw {
message: result.err.msg + '(line ' + result.err.line + ')',
}
}
let jsonObj = parser.parse(graphML, options)
data.nodes.add(
jsonObj.graphml.graph.node.map((n) => {
return {
id: n.attr.id.toString(),
label: getLabel(n.data),
}
})
)
data.edges.add(
jsonObj.graphml.graph.edge.map((e) => {
return {
id: e.attr.id.toString(),
from: e.attr.source.toString(),
to: e.attr.target.toString(),
}
})
)
function getLabel(arr) {
for (let at of arr) {
if (at.attr.key == 'label') return at.txt
}
}
}
/**
* Parse and load a GML file
* @param {string} gml
*/
function loadGML(gml) {
if (gml.search('graph') < 0) throw {message: '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 {
message: `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 {message: 'Sheet named Factors not found in Workbook'}
let linksSS = workbook.Sheets['Links']
if (!linksSS) throw {message: '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 between 1 and 9
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))
factors.forEach((f) => {
f.id = uuidv4()
if (f.style) {
let styleNo = parseInt(f.style)
if (isNaN(styleNo) || styleNo < 1 || styleNo > 9) {
throw {
message: `Factors - Line ${f.__rowNum__}: Style must be a number between 1 and 9, a style name, 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 {
message: `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 {
message: `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 {message: `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 {message: `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 json = JSON.stringify(
{
saved: new Date(Date.now()).toLocaleString(),
version: version,
room: room,
mapTitle: elem('maptitle').innerText,
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: [
'id',
'label',
'note',
'grp',
'x',
'y',
'arrows',
'color',
'font',
'borderWidth',
'shape',
'shapeProperties',
'margin',
'thumbUp',
'thumbDown',
'created',
'modified',
],
filter: (n) => !n.isCluster,
}),
edges: data.edges.get({
fields: ['id', 'label', 'note', 'grp', 'from', 'to', 'color', 'width', 'dashes', 'created', 'modified'],
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', 'All')
}
/**
* 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: 'straightCross',
},
},
})
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 docAsBlob = await quillToWord.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',
'id',
'labelHighlightBold',
'locked',
'margin',
'modified',
'opacity',
'oldFont',
'oldFontColor',
'oldLabel',
'note',
'scaling',
'shapeProperties',
'wasFixed',
])
})
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
}
}