SVGHelpers.js 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. import {
  2. init,
  3. classModule,
  4. propsModule,
  5. styleModule,
  6. eventListenersModule,
  7. h,
  8. attributesModule,
  9. } from 'snabbdom';
  10. const patch = init([
  11. attributesModule,
  12. classModule,
  13. propsModule,
  14. styleModule,
  15. eventListenersModule,
  16. ]);
  17. function mountDummySVGContainer(canvas) {
  18. const container = canvas.parentElement;
  19. const dummy = document.createElement('div');
  20. container.insertBefore(dummy, canvas.nextSibling);
  21. const containerStyles = window.getComputedStyle(container);
  22. if (containerStyles.position === 'static') {
  23. container.style.position = 'relative';
  24. }
  25. return dummy;
  26. }
  27. export const VerticalTextAlignment = {
  28. TOP: 1,
  29. MIDDLE: 2,
  30. BOTTOM: 3,
  31. };
  32. /**
  33. * Computes the relative dy values around 0 for multiline text.
  34. *
  35. * @param nLines
  36. * @param fontSize
  37. * @returns a list of vertical offsets (from a zero origin) for placing multiline text.
  38. */
  39. export function multiLineTextCalculator(
  40. nLines,
  41. fontSize,
  42. alignment = VerticalTextAlignment.BOTTOM
  43. ) {
  44. const dys = [];
  45. for (let i = 0; i < nLines; i++) {
  46. switch (alignment) {
  47. case VerticalTextAlignment.TOP:
  48. dys.push(fontSize * (i + 1));
  49. break;
  50. case VerticalTextAlignment.MIDDLE:
  51. dys.push(-fontSize * (0.5 * nLines - i - 1));
  52. break;
  53. case VerticalTextAlignment.BOTTOM:
  54. default:
  55. dys.push(-fontSize * (nLines - i - 1));
  56. }
  57. }
  58. return dys;
  59. }
  60. /**
  61. * Automatically updates an SVG rendering whenever a widget's state is updated.
  62. *
  63. * This update is done in two phases:
  64. * 1. mapState(widgetState) takes the widget state and transforms it into an intermediate data representation.
  65. * 2. render(data, h) takes the intermediate data representation and a createElement `h` function, and returns
  66. * an SVG rendering of the state encoded in `data`.
  67. *
  68. * See snabbdom's documentation for how to use the `h` function passed to `render()`.
  69. *
  70. * @param renderer the widget manager's renderer
  71. * @param widgetState the widget state
  72. * @param mapState (object parameter) transforms the given widget's state into an intermediate data representation to be passed to render().
  73. * @param render (object parameter) returns the SVG representation given the data from mapState() and snabbdom's h render function.
  74. */
  75. export function bindSVGRepresentation(
  76. renderer,
  77. widgetState,
  78. { mapState, render }
  79. ) {
  80. const view = renderer.getRenderWindow().getViews()[0];
  81. const canvas = view.getCanvas();
  82. const getSize = () => {
  83. const [width, height] = view.getSize();
  84. const ratio = window.devicePixelRatio || 1;
  85. return {
  86. width: width / ratio,
  87. height: height / ratio,
  88. viewBox: `0 0 ${width} ${height}`,
  89. };
  90. };
  91. const renderState = (state) => {
  92. const repData = mapState(state, {
  93. size: view.getSize(),
  94. });
  95. const rendered = render(repData, h);
  96. return h(
  97. 'svg',
  98. {
  99. attrs: getSize(),
  100. style: {
  101. position: 'absolute',
  102. top: '0',
  103. left: '0',
  104. width: '100%',
  105. height: '100%',
  106. // deny pointer events by default
  107. 'pointer-events': 'none',
  108. },
  109. },
  110. Array.isArray(rendered) ? rendered : [rendered]
  111. );
  112. };
  113. const dummy = mountDummySVGContainer(canvas);
  114. let vnode = patch(dummy, renderState(widgetState));
  115. const updateVNode = () => {
  116. vnode = patch(vnode, renderState(widgetState));
  117. };
  118. const stateSub = widgetState.onModified(() => updateVNode());
  119. const cameraSub = renderer.getActiveCamera().onModified(() => updateVNode());
  120. const observer = new ResizeObserver(() => updateVNode());
  121. observer.observe(canvas);
  122. return () => {
  123. stateSub.unsubscribe();
  124. cameraSub.unsubscribe();
  125. observer.disconnect();
  126. patch(vnode, h('!')); // unmount hack
  127. vnode = null;
  128. };
  129. }
  130. /**
  131. * Applies a set of default interaction handling behavior.
  132. *
  133. * Typically, firing pointerenter means that the pointer is
  134. * "hovering", meaning the associated widget state should
  135. * be selected. (This is on the user to do this step.)
  136. * Accordingly, pointerleave means no more hovering.
  137. *
  138. * However, vtk.js captures the pointer on pointerdown,
  139. * which means that clicking on an SVG element will result
  140. * in a pointerleave being triggered, deselecting the
  141. * widget state.
  142. *
  143. * The abridged sequence of events is as follows:
  144. * 1. pointerenter on the SVG element (mouse moves over SVG handle)
  145. * 2. pointerdown on the SVG element (left button press)
  146. * 2. pointerdown on the vtk.js canvas (left button press)
  147. * 3. pointer captured on the vtk.js canvas (left button press)
  148. * 4. pointerleave on the SVG element as soon as the mouse is moved,
  149. * since the capture target is now the canvas.
  150. * 5. pointerenter on the SVG element when the mouse/pointer is released.
  151. *
  152. * To workaround this issue, we conditionally fire the user's
  153. * pointerleave listener only when we are "locked", which means
  154. * we saw a pointerdown and so the vtk.js canvas is capturing
  155. * the current pointer.
  156. */
  157. function applyDefaultInteractions(userListeners) {
  158. let locked = false;
  159. return {
  160. ...userListeners,
  161. pointerdown(ev) {
  162. locked = true;
  163. return userListeners?.pointerdown?.(ev);
  164. },
  165. pointerenter(ev) {
  166. if (locked) {
  167. locked = false;
  168. }
  169. return userListeners?.pointerenter?.(ev);
  170. },
  171. pointerleave(ev) {
  172. if (!locked) {
  173. return userListeners?.pointerleave?.(ev);
  174. }
  175. return undefined;
  176. },
  177. };
  178. }
  179. /**
  180. * Requires the snabbdom eventlisteners and style modules.
  181. * @param vnode
  182. * @returns
  183. */
  184. export function makeListenableSVGNode(vnode) {
  185. // allow pointer events on this vnode
  186. vnode.data.style = {
  187. ...vnode.data.style,
  188. 'pointer-events': 'all',
  189. };
  190. vnode.data.on = applyDefaultInteractions(vnode.data.on);
  191. return vnode;
  192. }
  193. export default { bindSVGRepresentation, multiLineTextCalculator };