vis.js is a dynamic, browser-based visualization library
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

649 lines
21 KiB

  1. let util = require('../../../../util');
  2. let ComponentUtil = require('./ComponentUtil').default;
  3. let LabelSplitter = require('./LabelSplitter').default;
  4. /**
  5. * A Label to be used for Nodes or Edges.
  6. */
  7. class Label {
  8. /**
  9. * @param {Object} body
  10. * @param {Object} options
  11. * @param {boolean} [edgelabel=false]
  12. */
  13. constructor(body, options, edgelabel = false) {
  14. this.body = body;
  15. this.pointToSelf = false;
  16. this.baseSize = undefined;
  17. this.fontOptions = {};
  18. this.setOptions(options);
  19. this.size = {top: 0, left: 0, width: 0, height: 0, yLine: 0}; // could be cached
  20. this.isEdgeLabel = edgelabel;
  21. }
  22. /**
  23. *
  24. * @param {Object} options
  25. * @param {boolean} [allowDeletion=false]
  26. */
  27. setOptions(options, allowDeletion = false) {
  28. this.elementOptions = options;
  29. // We want to keep the font options separated from the node options.
  30. // The node options have to mirror the globals when they are not overruled.
  31. this.fontOptions = util.deepExtend({},options.font, true);
  32. if (options.label !== undefined) {
  33. this.labelDirty = true;
  34. }
  35. if (options.font !== undefined) {
  36. Label.parseOptions(this.fontOptions, options, allowDeletion);
  37. if (typeof options.font === 'string') {
  38. this.baseSize = this.fontOptions.size;
  39. }
  40. else if (typeof options.font === 'object') {
  41. if (options.font.size !== undefined) {
  42. this.baseSize = options.font.size;
  43. }
  44. }
  45. }
  46. }
  47. /**
  48. *
  49. * @param {Object} parentOptions
  50. * @param {Object} newOptions
  51. * @param {boolean} [allowDeletion=false]
  52. * @static
  53. */
  54. static parseOptions(parentOptions, newOptions, allowDeletion = false) {
  55. if (Label.parseFontString(parentOptions, newOptions.font)) {
  56. parentOptions.vadjust = 0;
  57. }
  58. else if (typeof newOptions.font === 'object') {
  59. util.fillIfDefined(parentOptions, newOptions.font, allowDeletion);
  60. }
  61. parentOptions.size = Number(parentOptions.size);
  62. parentOptions.vadjust = Number(parentOptions.vadjust);
  63. }
  64. /**
  65. * If in-variable is a string, parse it as a font specifier.
  66. *
  67. * Note that following is not done here and have to be done after the call:
  68. * - No number conversion (size)
  69. * - Not all font options are set (vadjust, mod)
  70. *
  71. * @param {Object} outOptions out-parameter, object in which to store the parse results (if any)
  72. * @param {Object} inOptions font options to parse
  73. * @return {boolean} true if font parsed as string, false otherwise
  74. * @static
  75. */
  76. static parseFontString(outOptions, inOptions) {
  77. if (!inOptions || typeof inOptions !== 'string') return false;
  78. let newOptionsArray = inOptions.split(" ");
  79. outOptions.size = newOptionsArray[0].replace("px",'');
  80. outOptions.face = newOptionsArray[1];
  81. outOptions.color = newOptionsArray[2];
  82. return true;
  83. }
  84. /**
  85. * Set the width and height constraints based on 'nearest' value
  86. * @param {Array} pile array of option objects to consider
  87. * @private
  88. */
  89. constrain(pile) {
  90. this.fontOptions.constrainWidth = false;
  91. this.fontOptions.maxWdt = -1;
  92. this.fontOptions.minWdt = -1;
  93. let widthConstraint = util.topMost(pile, 'widthConstraint');
  94. if (typeof widthConstraint === 'number') {
  95. this.fontOptions.maxWdt = Number(widthConstraint);
  96. this.fontOptions.minWdt = Number(widthConstraint);
  97. } else if (typeof widthConstraint === 'object') {
  98. let widthConstraintMaximum = util.topMost(pile, ['widthConstraint', 'maximum']);
  99. if (typeof widthConstraintMaximum === 'number') {
  100. this.fontOptions.maxWdt = Number(widthConstraintMaximum);
  101. }
  102. let widthConstraintMinimum = util.topMost(pile, ['widthConstraint', 'minimum'])
  103. if (typeof widthConstraintMinimum === 'number') {
  104. this.fontOptions.minWdt = Number(widthConstraintMinimum);
  105. }
  106. }
  107. this.fontOptions.constrainHeight = false;
  108. this.fontOptions.minHgt = -1;
  109. this.fontOptions.valign = 'middle';
  110. let heightConstraint = util.topMost(pile, 'heightConstraint');
  111. if (typeof heightConstraint === 'number') {
  112. this.fontOptions.minHgt = Number(heightConstraint);
  113. } else if (typeof heightConstraint === 'object') {
  114. let heightConstraintMinimum = util.topMost(pile, ['heightConstraint', 'minimum']);
  115. if (typeof heightConstraintMinimum === 'number') {
  116. this.fontOptions.minHgt = Number(heightConstraintMinimum);
  117. }
  118. let heightConstraintValign = util.topMost(pile, ['heightConstraint', 'valign']);
  119. if (typeof heightConstraintValign === 'string') {
  120. if ((heightConstraintValign === 'top')||(heightConstraintValign === 'bottom')) {
  121. this.fontOptions.valign = heightConstraintValign;
  122. }
  123. }
  124. }
  125. }
  126. /**
  127. * Set options and update internal state
  128. *
  129. * @param {Object} options options to set
  130. * @param {Array} pile array of option objects to consider for option 'chosen'
  131. */
  132. update(options, pile) {
  133. this.setOptions(options, true);
  134. this.constrain(pile);
  135. this.fontOptions.chooser = ComponentUtil.choosify('label', pile);
  136. }
  137. /**
  138. * When margins are set in an element, adjust sizes is called to remove them
  139. * from the width/height constraints. This must be done prior to label sizing.
  140. *
  141. * @param {{top: number, right: number, bottom: number, left: number}} margins
  142. */
  143. adjustSizes(margins) {
  144. let widthBias = (margins) ? (margins.right + margins.left) : 0;
  145. if (this.fontOptions.constrainWidth) {
  146. this.fontOptions.maxWdt -= widthBias;
  147. this.fontOptions.minWdt -= widthBias;
  148. }
  149. let heightBias = (margins) ? (margins.top + margins.bottom) : 0;
  150. if (this.fontOptions.constrainHeight) {
  151. this.fontOptions.minHgt -= heightBias;
  152. }
  153. }
  154. /**
  155. * Collapse the font options for the multi-font to single objects, from
  156. * the chain of option objects passed.
  157. *
  158. * If an option for a specific multi-font is not present, the parent
  159. * option is checked for the given option.
  160. *
  161. * NOTE: naming of 'groupOptions' is a misnomer; the actual value passed
  162. * is the new values to set from setOptions().
  163. *
  164. * @param {Object} options
  165. * @param {Object} groupOptions
  166. * @param {Object} defaultOptions
  167. */
  168. propagateFonts(options, groupOptions, defaultOptions) {
  169. if (!this.fontOptions.multi) return;
  170. /**
  171. * Resolve the font options path.
  172. * If valid, return a reference to the object in question.
  173. * Otherwise, just return null.
  174. *
  175. * @param {Object} options base object to determine path from
  176. * @param {'bold'|'ital'|'boldital'|'mono'|'normal'} [mod=undefined] if present, sub path for the mod-font
  177. * @returns {Object|null}
  178. */
  179. var pathP = function(options, mod) {
  180. if (!options || !options.font) return null;
  181. var opt = options.font;
  182. if (mod) {
  183. if (!opt[mod]) return null;
  184. opt = opt[mod];
  185. }
  186. return opt;
  187. };
  188. /**
  189. * Get property value from options.font[mod][property] if present.
  190. * If mod not passed, use property value from options.font[property].
  191. *
  192. * @param {Label.options} options
  193. * @param {'bold'|'ital'|'boldital'|'mono'|'normal'} mod
  194. * @param {string} property
  195. * @return {*|null} value if found, null otherwise.
  196. */
  197. var getP = function(options, mod, property) {
  198. let opt = pathP(options, mod);
  199. if (opt && opt.hasOwnProperty(property)) {
  200. return opt[property];
  201. }
  202. return null;
  203. };
  204. let mods = [ 'bold', 'ital', 'boldital', 'mono' ];
  205. for (const mod of mods) {
  206. let modOptions = this.fontOptions[mod];
  207. let modDefaults = defaultOptions.font[mod];
  208. if (Label.parseFontString(modOptions, pathP(options, mod))) {
  209. modOptions.vadjust = this.fontOptions.vadjust;
  210. modOptions.mod = modDefaults.mod;
  211. } else {
  212. // We need to be crafty about loading the modded fonts. We want as
  213. // much 'natural' versatility as we can get, so a simple global
  214. // change propagates in an expected way, even if not stictly logical.
  215. // 'face' has a special exception for mono, since we probably
  216. // don't want to sync to the base font face.
  217. modOptions.face =
  218. getP(options , mod, 'face') ||
  219. getP(groupOptions, mod, 'face') ||
  220. (mod === 'mono'? modDefaults.face:null ) ||
  221. getP(groupOptions, null, 'face') ||
  222. this.fontOptions.face
  223. ;
  224. // 'color' follows the standard flow
  225. modOptions.color =
  226. getP(options , mod, 'color') ||
  227. getP(groupOptions, mod, 'color') ||
  228. getP(groupOptions, null, 'color') ||
  229. this.fontOptions.color
  230. ;
  231. // 'mode' follows the standard flow
  232. modOptions.mod =
  233. getP(options , mod, 'mod') ||
  234. getP(groupOptions, mod, 'mod') ||
  235. getP(groupOptions, null, 'mod') ||
  236. modDefaults.mod
  237. ;
  238. // It's important that we size up defaults similarly if we're
  239. // using default faces unless overriden. We want to preserve the
  240. // ratios closely - but if faces have changed, all bets are off.
  241. let ratio;
  242. // NOTE: Following condition always fails, because modDefaults
  243. // has no explicit font property. This is deliberate, see
  244. // var's 'NodesHandler.defaultOptions.font[mod]'.
  245. // However, I want to keep the original logic while refactoring;
  246. // it appears to be working fine even if ratio is never set.
  247. // TODO: examine if this is a bug, fix if necessary.
  248. //
  249. if ((modOptions.face === modDefaults.face) &&
  250. (this.fontOptions.face === defaultOptions.font.face)) {
  251. ratio = this.fontOptions.size / Number(defaultOptions.font.size);
  252. }
  253. modOptions.size =
  254. getP(options , mod, 'size') ||
  255. getP(groupOptions, mod, 'size') ||
  256. (ratio? modDefaults.size * ratio: null) || // Scale the mod size using the same ratio
  257. getP(groupOptions, null, 'size') ||
  258. this.fontOptions.size
  259. ;
  260. modOptions.vadjust =
  261. getP(options , mod, 'vadjust') ||
  262. getP(groupOptions, mod, 'vadjust') ||
  263. (ratio? modDefaults.vadjust * Math.round(ratio): null) || // Scale it using the same ratio
  264. this.fontOptions.vadjust
  265. ;
  266. }
  267. modOptions.size = Number(modOptions.size);
  268. modOptions.vadjust = Number(modOptions.vadjust);
  269. }
  270. }
  271. /**
  272. * Main function. This is called from anything that wants to draw a label.
  273. * @param {CanvasRenderingContext2D} ctx
  274. * @param {number} x
  275. * @param {number} y
  276. * @param {boolean} selected
  277. * @param {boolean} hover
  278. * @param {string} [baseline='middle']
  279. */
  280. draw(ctx, x, y, selected, hover, baseline = 'middle') {
  281. // if no label, return
  282. if (this.elementOptions.label === undefined)
  283. return;
  284. // check if we have to render the label
  285. let viewFontSize = this.fontOptions.size * this.body.view.scale;
  286. if (this.elementOptions.label && viewFontSize < this.elementOptions.scaling.label.drawThreshold - 1)
  287. return;
  288. // update the size cache if required
  289. this.calculateLabelSize(ctx, selected, hover, x, y, baseline);
  290. this._drawBackground(ctx); // create the fontfill background
  291. this._drawText(ctx, selected, hover, x, y, baseline);
  292. }
  293. /**
  294. * Draws the label background
  295. * @param {CanvasRenderingContext2D} ctx
  296. * @private
  297. */
  298. _drawBackground(ctx) {
  299. if (this.fontOptions.background !== undefined && this.fontOptions.background !== "none") {
  300. ctx.fillStyle = this.fontOptions.background;
  301. let lineMargin = 2;
  302. if (this.isEdgeLabel) {
  303. switch (this.fontOptions.align) {
  304. case 'middle':
  305. ctx.fillRect(-this.size.width * 0.5, -this.size.height * 0.5, this.size.width, this.size.height);
  306. break;
  307. case 'top':
  308. ctx.fillRect(-this.size.width * 0.5, -(this.size.height + lineMargin), this.size.width, this.size.height);
  309. break;
  310. case 'bottom':
  311. ctx.fillRect(-this.size.width * 0.5, lineMargin, this.size.width, this.size.height);
  312. break;
  313. default:
  314. ctx.fillRect(this.size.left, this.size.top - 0.5*lineMargin, this.size.width, this.size.height);
  315. break;
  316. }
  317. } else {
  318. ctx.fillRect(this.size.left, this.size.top - 0.5*lineMargin, this.size.width, this.size.height);
  319. }
  320. }
  321. }
  322. /**
  323. *
  324. * @param {CanvasRenderingContext2D} ctx
  325. * @param {boolean} selected
  326. * @param {boolean} hover
  327. * @param {number} x
  328. * @param {number} y
  329. * @param {string} [baseline='middle']
  330. * @private
  331. */
  332. _drawText(ctx, selected, hover, x, y, baseline = 'middle') {
  333. let fontSize = this.fontOptions.size;
  334. let viewFontSize = fontSize * this.body.view.scale;
  335. // This ensures that there will not be HUGE letters on screen
  336. // by setting an upper limit on the visible text size (regardless of zoomLevel)
  337. if (viewFontSize >= this.elementOptions.scaling.label.maxVisible) {
  338. // TODO: Does this actually do anything?
  339. fontSize = Number(this.elementOptions.scaling.label.maxVisible) / this.body.view.scale;
  340. }
  341. let yLine = this.size.yLine;
  342. [x, yLine] = this._setAlignment(ctx, x, yLine, baseline);
  343. ctx.textAlign = 'left';
  344. x = x - this.size.width / 2; // Shift label 1/2-distance to the left
  345. if ((this.fontOptions.valign) && (this.size.height > this.size.labelHeight)) {
  346. if (this.fontOptions.valign === 'top') {
  347. yLine -= (this.size.height - this.size.labelHeight) / 2;
  348. }
  349. if (this.fontOptions.valign === 'bottom') {
  350. yLine += (this.size.height - this.size.labelHeight) / 2;
  351. }
  352. }
  353. // draw the text
  354. for (let i = 0; i < this.lineCount; i++) {
  355. if (this.lines[i] && this.lines[i].blocks) {
  356. let width = 0;
  357. if (this.isEdgeLabel || this.fontOptions.align === 'center') {
  358. width += (this.size.width - this.lines[i].width) / 2
  359. } else if (this.fontOptions.align === 'right') {
  360. width += (this.size.width - this.lines[i].width)
  361. }
  362. for (let j = 0; j < this.lines[i].blocks.length; j++) {
  363. let block = this.lines[i].blocks[j];
  364. ctx.font = block.font;
  365. let [fontColor, strokeColor] = this._getColor(block.color, viewFontSize, block.strokeColor);
  366. if (block.strokeWidth > 0) {
  367. ctx.lineWidth = block.strokeWidth;
  368. ctx.strokeStyle = strokeColor;
  369. ctx.lineJoin = 'round';
  370. }
  371. ctx.fillStyle = fontColor;
  372. if (block.strokeWidth > 0) {
  373. ctx.strokeText(block.text, x + width, yLine + block.vadjust);
  374. }
  375. ctx.fillText(block.text, x + width, yLine + block.vadjust);
  376. width += block.width;
  377. }
  378. yLine += this.lines[i].height;
  379. }
  380. }
  381. }
  382. /**
  383. *
  384. * @param {CanvasRenderingContext2D} ctx
  385. * @param {number} x
  386. * @param {number} yLine
  387. * @param {string} baseline
  388. * @returns {Array.<number>}
  389. * @private
  390. */
  391. _setAlignment(ctx, x, yLine, baseline) {
  392. // check for label alignment (for edges)
  393. // TODO: make alignment for nodes
  394. if (this.isEdgeLabel && this.fontOptions.align !== 'horizontal' && this.pointToSelf === false) {
  395. x = 0;
  396. yLine = 0;
  397. let lineMargin = 2;
  398. if (this.fontOptions.align === 'top') {
  399. ctx.textBaseline = 'alphabetic';
  400. yLine -= 2 * lineMargin; // distance from edge, required because we use alphabetic. Alphabetic has less difference between browsers
  401. }
  402. else if (this.fontOptions.align === 'bottom') {
  403. ctx.textBaseline = 'hanging';
  404. yLine += 2 * lineMargin;// distance from edge, required because we use hanging. Hanging has less difference between browsers
  405. }
  406. else {
  407. ctx.textBaseline = 'middle';
  408. }
  409. }
  410. else {
  411. ctx.textBaseline = baseline;
  412. }
  413. return [x,yLine];
  414. }
  415. /**
  416. * fade in when relative scale is between threshold and threshold - 1.
  417. * If the relative scale would be smaller than threshold -1 the draw function would have returned before coming here.
  418. *
  419. * @param {string} color The font color to use
  420. * @param {number} viewFontSize
  421. * @param {string} initialStrokeColor
  422. * @returns {Array.<string>} An array containing the font color and stroke color
  423. * @private
  424. */
  425. _getColor(color, viewFontSize, initialStrokeColor) {
  426. let fontColor = color || '#000000';
  427. let strokeColor = initialStrokeColor || '#ffffff';
  428. if (viewFontSize <= this.elementOptions.scaling.label.drawThreshold) {
  429. let opacity = Math.max(0, Math.min(1, 1 - (this.elementOptions.scaling.label.drawThreshold - viewFontSize)));
  430. fontColor = util.overrideOpacity(fontColor, opacity);
  431. strokeColor = util.overrideOpacity(strokeColor, opacity);
  432. }
  433. return [fontColor, strokeColor];
  434. }
  435. /**
  436. *
  437. * @param {CanvasRenderingContext2D} ctx
  438. * @param {boolean} selected
  439. * @param {boolean} hover
  440. * @returns {{width: number, height: number}}
  441. */
  442. getTextSize(ctx, selected = false, hover = false) {
  443. this._processLabel(ctx, selected, hover);
  444. return {
  445. width: this.size.width,
  446. height: this.size.height,
  447. lineCount: this.lineCount
  448. };
  449. }
  450. /**
  451. *
  452. * @param {CanvasRenderingContext2D} ctx
  453. * @param {boolean} selected
  454. * @param {boolean} hover
  455. * @param {number} [x=0]
  456. * @param {number} [y=0]
  457. * @param {'middle'|'hanging'} [baseline='middle']
  458. */
  459. calculateLabelSize(ctx, selected, hover, x = 0, y = 0, baseline = 'middle') {
  460. if (this.labelDirty === true) {
  461. this._processLabel(ctx, selected, hover);
  462. }
  463. this.size.left = x - this.size.width * 0.5;
  464. this.size.top = y - this.size.height * 0.5;
  465. this.size.yLine = y + (1 - this.lineCount) * 0.5 * this.fontOptions.size;
  466. if (baseline === "hanging") {
  467. this.size.top += 0.5 * this.fontOptions.size;
  468. this.size.top += 4; // distance from node, required because we use hanging. Hanging has less difference between browsers
  469. this.size.yLine += 4; // distance from node
  470. }
  471. this.labelDirty = false;
  472. }
  473. /**
  474. *
  475. * @param {CanvasRenderingContext2D} ctx
  476. * @param {boolean} selected
  477. * @param {boolean} hover
  478. * @param {string} mod
  479. * @returns {{color, size, face, mod, vadjust, strokeWidth: *, strokeColor: (*|string|allOptions.edges.font.strokeColor|{string}|allOptions.nodes.font.strokeColor|Array)}}
  480. */
  481. getFormattingValues(ctx, selected, hover, mod) {
  482. var getValue = function(fontOptions, mod, option) {
  483. if (mod === "normal") {
  484. if (option === 'mod' ) return "";
  485. return fontOptions[option];
  486. }
  487. if (fontOptions[mod][option]) {
  488. return fontOptions[mod][option];
  489. } else {
  490. // Take from parent font option
  491. return fontOptions[option];
  492. }
  493. };
  494. let values = {
  495. color : getValue(this.fontOptions, mod, 'color' ),
  496. size : getValue(this.fontOptions, mod, 'size' ),
  497. face : getValue(this.fontOptions, mod, 'face' ),
  498. mod : getValue(this.fontOptions, mod, 'mod' ),
  499. vadjust: getValue(this.fontOptions, mod, 'vadjust'),
  500. strokeWidth: this.fontOptions.strokeWidth,
  501. strokeColor: this.fontOptions.strokeColor
  502. };
  503. if (selected || hover) {
  504. if (mod === "normal" && (this.fontOptions.chooser === true) && (this.elementOptions.labelHighlightBold)) {
  505. values.mod = 'bold';
  506. } else {
  507. if (typeof this.fontOptions.chooser === 'function') {
  508. this.fontOptions.chooser(values, this.elementOptions.id, selected, hover);
  509. }
  510. }
  511. }
  512. ctx.font = (values.mod + " " + values.size + "px " + values.face).replace(/"/g, "");
  513. values.font = ctx.font;
  514. values.height = values.size;
  515. return values;
  516. }
  517. /**
  518. *
  519. * @param {boolean} selected
  520. * @param {boolean} hover
  521. * @returns {boolean}
  522. */
  523. differentState(selected, hover) {
  524. return ((selected !== this.fontOptions.selectedState) && (hover !== this.fontOptions.hoverState));
  525. }
  526. /**
  527. * This explodes the passed text into lines and determines the width, height and number of lines.
  528. *
  529. * @param {CanvasRenderingContext2D} ctx
  530. * @param {boolean} selected
  531. * @param {boolean} hover
  532. * @param {string} inText the text to explode
  533. * @returns {{width, height, lines}|*}
  534. * @private
  535. */
  536. _processLabelText(ctx, selected, hover, inText) {
  537. let splitter = new LabelSplitter(ctx, this, selected, hover);
  538. return splitter.process(inText);
  539. }
  540. /**
  541. * This explodes the label string into lines and sets the width, height and number of lines.
  542. * @param {CanvasRenderingContext2D} ctx
  543. * @param {boolean} selected
  544. * @param {boolean} hover
  545. * @private
  546. */
  547. _processLabel(ctx, selected, hover) {
  548. let state = this._processLabelText(ctx, selected, hover, this.elementOptions.label);
  549. if ((this.fontOptions.minWdt > 0) && (state.width < this.fontOptions.minWdt)) {
  550. state.width = this.fontOptions.minWdt;
  551. }
  552. this.size.labelHeight =state.height;
  553. if ((this.fontOptions.minHgt > 0) && (state.height < this.fontOptions.minHgt)) {
  554. state.height = this.fontOptions.minHgt;
  555. }
  556. this.lines = state.lines;
  557. this.lineCount = state.lines.length;
  558. this.size.width = state.width;
  559. this.size.height = state.height;
  560. this.selectedState = selected;
  561. this.hoverState = hover;
  562. }
  563. }
  564. export default Label;