symbology.js

  1. /* jshint maxcomplexity: false */
  2. 'use strict';
  3. const svgjs = require('svg.js');
  4. const shared = require('./shared.js')();
  5. // Functions for turning ESRI Renderers into images
  6. // Specifically, converting ESRI "Simple" symbols into images,
  7. // and deriving the appropriate image for a feature based on
  8. // a renderer
  9. // layer symbology types
  10. const SIMPLE = 'simple';
  11. const UNIQUE_VALUE = 'uniqueValue';
  12. const CLASS_BREAKS = 'classBreaks';
  13. const CONTAINER_SIZE = 32; // size of the symbology item container
  14. const CONTENT_SIZE = 24; // size of the symbology graphic
  15. const CONTENT_IMAGE_SIZE = 28; // size of the symbology graphic if it's an image (images tend to already have a white boarder around them)
  16. const CONTAINER_CENTER = CONTAINER_SIZE / 2;
  17. const CONTENT_PADDING = (CONTAINER_SIZE - CONTENT_SIZE) / 2;
  18. /**
  19. * Will add extra properties to a renderer to support images.
  20. * New properties .svgcode and .defaultsvgcode contains image source
  21. * for app on each renderer item.
  22. *
  23. * @param {Object} renderer an ESRI renderer object in server JSON form. Param is modified in place
  24. * @param {Object} legend object for the layer that maps legend label to data url of legend image
  25. * @return {Promise} resolving when the renderer has been enhanced
  26. */
  27. function enhanceRenderer(renderer, legend) {
  28. // TODO note somewhere (user docs) that everything fails if someone publishes a legend with two identical labels
  29. // quick lookup object of legend names to data URLs.
  30. // our legend object is in ESRI format, but was generated by us and only has info for a single layer.
  31. // so we just grab item 0, which is the only item.
  32. const legendLookup = {};
  33. // store svgcode in the lookup
  34. const legendItemPromises = legend.layers[0].legend.map(legItem =>
  35. legItem.then(data =>
  36. legendLookup[data.label] = data.svgcode
  37. ));
  38. // wait until all legend items are resolved and legend lookup is updated
  39. return Promise.all(legendItemPromises).then(() => {
  40. switch (renderer.type) {
  41. case SIMPLE:
  42. renderer.svgcode = legendLookup[renderer.label];
  43. break;
  44. case UNIQUE_VALUE:
  45. if (renderer.defaultLabel) {
  46. renderer.defaultsvgcode = legendLookup[renderer.defaultLabel];
  47. }
  48. renderer.uniqueValueInfos.forEach(uvi => {
  49. uvi.svgcode = legendLookup[uvi.label];
  50. });
  51. break;
  52. case CLASS_BREAKS:
  53. if (renderer.defaultLabel) {
  54. renderer.defaultsvgcode = legendLookup[renderer.defaultLabel];
  55. }
  56. renderer.classBreakInfos.forEach(cbi => {
  57. cbi.svgcode = legendLookup[cbi.label];
  58. });
  59. break;
  60. default:
  61. // Renderer we dont support
  62. console.warn('encountered unsupported renderer type: ' + renderer.type);
  63. }
  64. });
  65. }
  66. /**
  67. * Given feature attributes, find the renderer node that would draw it
  68. *
  69. * @method searchRenderer
  70. * @param {Object} attributes object of feature attribute key value pairs
  71. * @param {Object} renderer an enhanced renderer (see function enhanceRenderer)
  72. * @return {Object} an Object with svgcode and symbol properties for the matched renderer item
  73. */
  74. function searchRenderer(attributes, renderer) {
  75. let svgcode;
  76. let symbol = {};
  77. switch (renderer.type) {
  78. case SIMPLE:
  79. svgcode = renderer.svgcode;
  80. symbol = renderer.symbol;
  81. break;
  82. case UNIQUE_VALUE:
  83. // make a key value for the graphic in question, using comma-space delimiter if multiple fields
  84. // put an empty string when key value is null
  85. let graphicKey = attributes[renderer.field1] === null ? '' : attributes[renderer.field1];
  86. // all key values are stored as strings. if the attribute is in a numeric column, we must convert it to a string to ensure the === operator still works.
  87. if (typeof graphicKey !== 'string') {
  88. graphicKey = graphicKey.toString();
  89. }
  90. if (renderer.field2) {
  91. graphicKey = graphicKey + ', ' + attributes[renderer.field2];
  92. if (renderer.field3) {
  93. graphicKey = graphicKey + ', ' + attributes[renderer.field3];
  94. }
  95. }
  96. // search the value maps for a matching entry. if no match found, use the default image
  97. const uvi = renderer.uniqueValueInfos.find(uvi => uvi.value === graphicKey);
  98. if (uvi) {
  99. svgcode = uvi.svgcode;
  100. symbol = uvi.symbol;
  101. } else {
  102. svgcode = renderer.defaultsvgcode;
  103. symbol = renderer.defaultSymbol;
  104. }
  105. break;
  106. case CLASS_BREAKS:
  107. const gVal = parseFloat(attributes[renderer.field]);
  108. const lower = renderer.minValue;
  109. svgcode = renderer.defaultsvgcode;
  110. symbol = renderer.defaultSymbol;
  111. // check for outside range on the low end
  112. if (gVal < lower) { break; }
  113. // array of minimum values of the ranges in the renderer
  114. let minSplits = renderer.classBreakInfos.map(cbi => cbi.classMaxValue);
  115. minSplits.splice(0, 0, lower - 1); // put lower-1 at the start of the array and shift all other entries by 1
  116. // attempt to find the range our gVal belongs in
  117. const cbi = renderer.classBreakInfos.find((cbi, index) => gVal > minSplits[index] &&
  118. gVal <= cbi.classMaxValue);
  119. if (!cbi) { // outside of range on the high end
  120. break;
  121. }
  122. svgcode = cbi.svgcode;
  123. symbol = cbi.symbol;
  124. break;
  125. default:
  126. // TODO set svgcode to blank image?
  127. console.warn(`Unknown renderer type encountered - ${renderer.type}`);
  128. }
  129. // make an empty svg graphic in case nothing is found to avoid undefined inside the filters
  130. if (typeof svgcode === 'undefined') {
  131. svgcode = svgjs(document.createElement('div')).svg();
  132. }
  133. return { svgcode, symbol };
  134. }
  135. /**
  136. * Given feature attributes, return the image URL for that feature/graphic object.
  137. *
  138. * @method getGraphicIcon
  139. * @param {Object} attributes object of feature attribute key value pairs
  140. * @param {Object} renderer an enhanced renderer (see function enhanceRenderer)
  141. * @return {String} svgcode Url to the features symbology image
  142. */
  143. function getGraphicIcon(attributes, renderer) {
  144. const renderInfo = searchRenderer(attributes, renderer);
  145. return renderInfo.svgcode;
  146. }
  147. /**
  148. * Given feature attributes, return the symbol for that feature/graphic object.
  149. *
  150. * @method getGraphicSymbol
  151. * @param {Object} attributes object of feature attribute key value pairs
  152. * @param {Object} renderer an enhanced renderer (see function enhanceRenderer)
  153. * @return {Object} an ESRI Symbol object in server format
  154. */
  155. function getGraphicSymbol(attributes, renderer) {
  156. const renderInfo = searchRenderer(attributes, renderer);
  157. return renderInfo.symbol;
  158. }
  159. /**
  160. * Generates svg symbology for WMS layers.
  161. * @function generateWMSSymbology
  162. * @param {String} name label for the symbology item (it's not used right now, but is required to be consistent with other symbology generating functions)
  163. * @param {String} imageUri url or dataUrl of the legend image
  164. * @return {Promise} a promise resolving with symbology svg code and its label
  165. */
  166. function generateWMSSymbology(name, imageUri) {
  167. const draw = svgjs(window.document.createElement('div'))
  168. .size(CONTAINER_SIZE, CONTAINER_SIZE)
  169. .viewbox(0, 0, 0, 0);
  170. const symbologyItem = {
  171. name,
  172. svgcode: null
  173. };
  174. if (imageUri) {
  175. const symbologyPromise = shared.convertImagetoDataURL(imageUri)
  176. .then(imageUri =>
  177. svgDrawImage(draw, imageUri))
  178. .then(({ loader }) => {
  179. draw.viewbox(0, 0, loader.width, loader.height);
  180. symbologyItem.svgcode = draw.svg();
  181. return symbologyItem;
  182. })
  183. .catch(err => {
  184. console.error('Cannot draw wms legend image; returning empty', err);
  185. symbologyItem.svgcode = draw.svg();
  186. });
  187. return symbologyPromise;
  188. } else {
  189. symbologyItem.svgcode = draw.svg();
  190. return Promise.resolve(symbologyItem);
  191. }
  192. }
  193. /**
  194. * Generates a placeholder symbology graphic. Returns a promise for consistency
  195. * @function generatePlaceholderSymbology
  196. * @private
  197. * @param {String} name label symbology label
  198. * @param {String} colour colour to use in the graphic
  199. * @return {Promise} promise resolving with symbology svg code and its label
  200. */
  201. function generatePlaceholderSymbology(name, colour = '#000') {
  202. const draw = svgjs(window.document.createElement('div'))
  203. .size(CONTAINER_SIZE, CONTAINER_SIZE)
  204. .viewbox(0, 0, CONTAINER_SIZE, CONTAINER_SIZE);
  205. draw.rect(CONTENT_IMAGE_SIZE, CONTENT_IMAGE_SIZE)
  206. .center(CONTAINER_CENTER, CONTAINER_CENTER)
  207. .fill(colour);
  208. draw
  209. .text(name[0].toUpperCase()) // take the first letter
  210. .size(23)
  211. .fill('#fff')
  212. .attr({
  213. 'font-weight': 'bold',
  214. 'font-family': 'Roboto'
  215. })
  216. .center(CONTAINER_CENTER, CONTAINER_CENTER);
  217. return Promise.resolve({
  218. name,
  219. svgcode: draw.svg()
  220. });
  221. }
  222. /**
  223. * Generate a legend item for an ESRI symbol.
  224. * @private
  225. * @param {Object} symbol an ESRI symbol object in server format
  226. * @param {String} label label of the legend item
  227. * @param {Object} window reference to the browser window
  228. * @return {Object} a legend object populated with the symbol and label
  229. */
  230. function symbolToLegend(symbol, label, window) {
  231. // create a temporary svg element and add it to the page; if not added, the element's bounding box cannot be calculated correctly
  232. const container = window.document.createElement('div');
  233. container.setAttribute('style', 'opacity:0;position:fixed;left:100%;top:100%;overflow:hidden');
  234. window.document.body.appendChild(container);
  235. const draw = svgjs(container)
  236. .size(CONTAINER_SIZE, CONTAINER_SIZE)
  237. .viewbox(0, 0, CONTAINER_SIZE, CONTAINER_SIZE);
  238. // functions to draw esri simple marker symbols
  239. // jscs doesn't like enhanced object notation
  240. // jscs:disable requireSpacesInAnonymousFunctionExpression
  241. const esriSimpleMarkerSimbol = {
  242. esriSMSPath({ size, path }) {
  243. return draw.path(path).size(size);
  244. },
  245. esriSMSCircle({ size }) {
  246. return draw.circle(size);
  247. },
  248. esriSMSCross({ size }) {
  249. return draw.path('M 0,10 L 20,10 M 10,0 L 10,20').size(size);
  250. },
  251. esriSMSX({ size }) {
  252. return draw.path('M 0,0 L 20,20 M 20,0 L 0,20').size(size);
  253. },
  254. esriSMSTriangle({ size }) {
  255. return draw.path('M 20,20 L 10,0 0,20 Z').size(size);
  256. },
  257. esriSMSDiamond({ size }) {
  258. return draw.path('M 20,10 L 10,0 0,10 10,20 Z').size(size);
  259. },
  260. esriSMSSquare({ size }) {
  261. return draw.path('M 0,0 20,0 20,20 0,20 Z').size(size);
  262. }
  263. };
  264. // jscs:enable requireSpacesInAnonymousFunctionExpression
  265. // line dash styles
  266. const ESRI_DASH_MAPS = {
  267. esriSLSSolid: 'none',
  268. esriSLSDash: '5.333,4',
  269. esriSLSDashDot: '5.333,4,1.333,4',
  270. esriSLSLongDashDotDot: '10.666,4,1.333,4,1.333,4',
  271. esriSLSDot: '1.333,4',
  272. esriSLSLongDash: '10.666,4',
  273. esriSLSLongDashDot: '10.666,4,1.333,4',
  274. esriSLSShortDash: '5.333,1.333',
  275. esriSLSShortDashDot: '5.333,1.333,1.333,1.333',
  276. esriSLSShortDashDotDot: '5.333,1.333,1.333,1.333,1.333,1.333',
  277. esriSLSShortDot: '1.333,1.333',
  278. esriSLSNull: 'none'
  279. };
  280. // default stroke style
  281. const DEFAULT_STROKE = {
  282. color: '#000',
  283. opacity: 1,
  284. width: 1,
  285. linecap: 'square',
  286. linejoin: 'miter',
  287. miterlimit: 4
  288. };
  289. // this is a null outline in case a supplied symbol doesn't have one
  290. const DEFAULT_OUTLINE = {
  291. color: [0, 0, 0, 0],
  292. width: 0,
  293. style: ESRI_DASH_MAPS.esriSLSNull
  294. };
  295. // 5x5 px patter with coloured diagonal lines
  296. const esriSFSFills = {
  297. esriSFSSolid: symbolColour => {
  298. return {
  299. color: symbolColour.colour,
  300. opacity: symbolColour.opacity
  301. };
  302. },
  303. esriSFSNull: () => 'transparent',
  304. esriSFSHorizontal: (symbolColour, symbolStroke) => {
  305. const cellSize = 5;
  306. // patter fill: horizonal line in a 5x5 px square
  307. return draw.pattern(cellSize, cellSize, add =>
  308. add.line(0, cellSize / 2, cellSize, cellSize / 2)).stroke(symbolStroke);
  309. },
  310. esriSFSVertical: (symbolColour, symbolStroke) => {
  311. const cellSize = 5;
  312. // patter fill: vertical line in a 5x5 px square
  313. return draw.pattern(cellSize, cellSize, add =>
  314. add.line(cellSize / 2, 0, cellSize / 2, cellSize)).stroke(symbolStroke);
  315. },
  316. esriSFSForwardDiagonal: (symbolColour, symbolStroke) => {
  317. const cellSize = 5;
  318. // patter fill: forward diagonal line in a 5x5 px square; two more diagonal lines offset to cover the corners when the main line is cut off
  319. return draw.pattern(cellSize, cellSize, add => {
  320. add.line(0, 0, cellSize, cellSize).stroke(symbolStroke);
  321. add.line(0, 0, cellSize, cellSize).move(0, cellSize).stroke(symbolStroke);
  322. add.line(0, 0, cellSize, cellSize).move(cellSize, 0).stroke(symbolStroke);
  323. });
  324. },
  325. esriSFSBackwardDiagonal: (symbolColour, symbolStroke) => {
  326. const cellSize = 5;
  327. // patter fill: backward diagonal line in a 5x5 px square; two more diagonal lines offset to cover the corners when the main line is cut off
  328. return draw.pattern(cellSize, cellSize, add => {
  329. add.line(cellSize, 0, 0, cellSize).stroke(symbolStroke);
  330. add.line(cellSize, 0, 0, cellSize).move(cellSize / 2, cellSize / 2).stroke(symbolStroke);
  331. add.line(cellSize, 0, 0, cellSize).move(-cellSize / 2, -cellSize / 2).stroke(symbolStroke);
  332. });
  333. },
  334. esriSFSCross: (symbolColour, symbolStroke) => {
  335. const cellSize = 5;
  336. // patter fill: horizonal and vertical lines in a 5x5 px square
  337. return draw.pattern(cellSize, cellSize, add => {
  338. add.line(cellSize / 2, 0, cellSize / 2, cellSize).stroke(symbolStroke);
  339. add.line(0, cellSize / 2, cellSize, cellSize / 2).stroke(symbolStroke);
  340. });
  341. },
  342. esriSFSDiagonalCross: (symbolColour, symbolStroke) => {
  343. const cellSize = 7;
  344. // patter fill: crossing diagonal lines in a 7x7 px square
  345. return draw.pattern(cellSize, cellSize, add => {
  346. add.line(0, 0, cellSize, cellSize).stroke(symbolStroke);
  347. add.line(cellSize, 0, 0, cellSize).stroke(symbolStroke);
  348. });
  349. }
  350. };
  351. // jscs doesn't like enhanced object notation
  352. // jscs:disable requireSpacesInAnonymousFunctionExpression
  353. const symbolTypes = {
  354. esriSMS() { // ESRI Simple Marker Symbol
  355. const symbolColour = parseEsriColour(symbol.color);
  356. symbol.outline = symbol.outline || DEFAULT_OUTLINE;
  357. const outlineColour = parseEsriColour(symbol.outline.color);
  358. const outlineStroke = makeStroke({
  359. color: outlineColour.colour,
  360. opacity: outlineColour.opacity,
  361. width: symbol.outline.width,
  362. dasharray: ESRI_DASH_MAPS[symbol.outline.style]
  363. });
  364. // make an ESRI simple symbol and apply fill and outline to it
  365. const marker = esriSimpleMarkerSimbol[symbol.style](symbol)
  366. .fill({
  367. color: symbolColour.colour,
  368. opacity: symbolColour.opacity
  369. })
  370. .stroke(outlineStroke)
  371. .center(CONTAINER_CENTER, CONTAINER_CENTER)
  372. .rotate(symbol.angle || 0);
  373. fitInto(marker, CONTENT_SIZE);
  374. },
  375. esriSLS() { // ESRI Simple Line Symbol
  376. const lineColour = parseEsriColour(symbol.color);
  377. const lineStroke = makeStroke({
  378. color: lineColour.colour,
  379. opacity: lineColour.opacity,
  380. width: symbol.width,
  381. linecap: 'butt',
  382. dasharray: ESRI_DASH_MAPS[symbol.style]
  383. });
  384. const min = CONTENT_PADDING;
  385. const max = CONTAINER_SIZE - CONTENT_PADDING;
  386. draw.line(min, min, max, max)
  387. .stroke(lineStroke);
  388. },
  389. esriCLS() { // ESRI Fancy Line Symbol
  390. this.esriSLS();
  391. },
  392. esriSFS() { // ESRI Simple Fill Symbol
  393. const symbolColour = parseEsriColour(symbol.color);
  394. const symbolStroke = makeStroke({
  395. color: symbolColour.colour,
  396. opacity: symbolColour.opacity
  397. });
  398. const symbolFill = esriSFSFills[symbol.style](symbolColour, symbolStroke);
  399. symbol.outline = symbol.outline || DEFAULT_OUTLINE;
  400. const outlineColour = parseEsriColour(symbol.outline.color);
  401. const outlineStroke = makeStroke({
  402. color: outlineColour.colour,
  403. opacity: outlineColour.opacity,
  404. width: symbol.outline.width,
  405. linecap: 'butt',
  406. dasharray: ESRI_DASH_MAPS[symbol.outline.style]
  407. });
  408. draw.rect(CONTENT_SIZE, CONTENT_SIZE)
  409. .center(CONTAINER_CENTER, CONTAINER_CENTER)
  410. .fill(symbolFill)
  411. .stroke(outlineStroke);
  412. },
  413. esriTS() {
  414. console.error('no support for feature service legend of text symbols');
  415. },
  416. esriPFS() { // ESRI Picture Fill Symbol
  417. // imageUri can be just an image url is specified or a dataUri string
  418. const imageUri = symbol.imageData ? `data:${symbol.contentType};base64,${symbol.imageData}` : symbol.url;
  419. const imageWidth = symbol.width * symbol.xscale;
  420. const imageHeight = symbol.height * symbol.yscale;
  421. symbol.outline = symbol.outline || DEFAULT_OUTLINE;
  422. const outlineColour = parseEsriColour(symbol.outline.color);
  423. const outlineStroke = makeStroke({
  424. color: outlineColour.colour,
  425. opacity: outlineColour.opacity,
  426. width: symbol.outline.width,
  427. dasharray: ESRI_DASH_MAPS[symbol.outline.style]
  428. });
  429. const picturePromise = shared.convertImagetoDataURL(imageUri)
  430. .then(imageUri => {
  431. // make a fill from a tiled image
  432. const symbolFill = draw.pattern(imageWidth, imageHeight, add =>
  433. add.image(imageUri, imageWidth, imageHeight, true));
  434. draw.rect(CONTENT_SIZE, CONTENT_SIZE)
  435. .center(CONTAINER_CENTER, CONTAINER_CENTER)
  436. .fill(symbolFill)
  437. .stroke(outlineStroke);
  438. });
  439. return picturePromise;
  440. },
  441. esriPMS() { // ESRI PMS? Picture Marker Symbol
  442. // imageUri can be just an image url is specified or a dataUri string
  443. const imageUri = symbol.imageData ? `data:${symbol.contentType};base64,${symbol.imageData}` : symbol.url;
  444. // need to draw the image to get its size (technically not needed if we have a url, but this is simpler)
  445. const picturePromise = shared.convertImagetoDataURL(imageUri)
  446. .then(imageUri =>
  447. svgDrawImage(draw, imageUri))
  448. .then(({ image }) => {
  449. image
  450. .center(CONTAINER_CENTER, CONTAINER_CENTER)
  451. .rotate(symbol.angle || 0);
  452. // scale image to fit into the symbology item container
  453. fitInto(image, CONTENT_IMAGE_SIZE);
  454. });
  455. return picturePromise;
  456. }
  457. };
  458. // jscs:enable requireSpacesInAnonymousFunctionExpression
  459. // console.log(symbol.type, label, '--START--');
  460. // console.log(symbol);
  461. return Promise.resolve(symbolTypes[symbol.type]())
  462. .then(() => {
  463. // console.log(symbol.type, label, '--DONE--');
  464. // remove element from the page
  465. window.document.body.removeChild(container);
  466. return { label, svgcode: draw.svg() };
  467. }).catch(error => console.log(error));
  468. /**
  469. * Creates a stroke style by applying custom rules to the default stroke.
  470. * @param {Object} overrides any custom rules to apply on top of the defaults
  471. * @return {Object} a stroke object
  472. * @private
  473. */
  474. function makeStroke(overrides) {
  475. return Object.assign({}, DEFAULT_STROKE, overrides);
  476. }
  477. /**
  478. * Fits svg element in the size specified
  479. * @param {Ojbect} element svg element to fit
  480. * @param {Number} CONTAINER_SIZE width/height of a container to fit the element into
  481. */
  482. function fitInto(element, CONTAINER_SIZE) {
  483. // const elementRbox = element.rbox();
  484. // const elementRbox = element.screenBBox();
  485. const elementRbox = element.node.getBoundingClientRect(); // marker.rbox(); //rbox doesn't work properly in Chrome for some reason
  486. const scale = CONTAINER_SIZE / Math.max(elementRbox.width, elementRbox.height);
  487. if (scale < 1) {
  488. element.scale(scale);
  489. }
  490. }
  491. /**
  492. * Convert an ESRI colour object to SVG rgb format.
  493. * @private
  494. * @param {Array} c ESRI Colour array
  495. * @return {Object} colour and opacity in SVG format
  496. */
  497. function parseEsriColour(c) {
  498. if (c) {
  499. return {
  500. colour: `rgb(${c[0]},${c[1]},${c[2]})`,
  501. opacity: c[3] / 255
  502. };
  503. } else {
  504. return {
  505. colour: 'rgb(0, 0, 0)',
  506. opacity: 0
  507. };
  508. }
  509. }
  510. }
  511. /**
  512. * Renders a specified image on an svg element. This is a helper function that wraps around async `draw.image` call in the svg library.
  513. *
  514. * @function svgDrawImage
  515. * @private
  516. * @param {Object} draw svg element to render the image onto
  517. * @param {String} imageUri image url or dataURL of the image to render
  518. * @param {Number} width [optional = 0] width of the image
  519. * @param {Number} height [optional = 0] height of the image
  520. * @param {Boolean} crossOrigin [optional = true] specifies if the image should be loaded as crossOrigin
  521. * @return {Promise} promise resolving with the loaded image and its loader object (see svg.js http://documentup.com/wout/svg.js#image for details)
  522. */
  523. function svgDrawImage(draw, imageUri, width = 0, height = 0, crossOrigin = true) {
  524. const promise = new Promise((resolve, reject) => {
  525. const image = draw.image(imageUri, width, height, crossOrigin)
  526. .loaded(loader =>
  527. resolve({ image, loader }))
  528. .error(err => {
  529. reject(err);
  530. console.error(err);
  531. });
  532. });
  533. return promise;
  534. }
  535. /**
  536. * Generate an array of legend items for an ESRI unique value or class breaks renderer.
  537. * @private
  538. * @param {Object} renderer an ESRI unique value or class breaks renderer
  539. * @param {Array} childList array of children items of the renderer
  540. * @param {Object} window reference to the browser window
  541. * @return {Array} a legend object populated with the symbol and label
  542. */
  543. function scrapeListRenderer(renderer, childList, window) {
  544. const legend = childList.map(child => {
  545. return symbolToLegend(child.symbol, child.label, window);
  546. });
  547. if (renderer.defaultSymbol) {
  548. // class breaks dont have default label
  549. // TODO perhaps put in a default of "Other", would need to be in proper language
  550. legend.push(symbolToLegend(renderer.defaultSymbol, renderer.defaultLabel || '', window));
  551. }
  552. return legend;
  553. }
  554. function buildRendererToLegend(window) {
  555. /**
  556. * Generate a legend object based on an ESRI renderer.
  557. * @private
  558. * @param {Object} renderer an ESRI renderer object in server JSON form
  559. * @param {Integer} index the layer index of this renderer
  560. * @return {Object} an object matching the form of an ESRI REST API legend
  561. */
  562. return (renderer, index) => {
  563. // make basic shell object with .layers array
  564. const legend = {
  565. layers: [{
  566. layerId: index,
  567. legend: []
  568. }]
  569. };
  570. switch (renderer.type) {
  571. case SIMPLE:
  572. legend.layers[0].legend.push(symbolToLegend(renderer.symbol, renderer.label, window));
  573. break;
  574. case UNIQUE_VALUE:
  575. legend.layers[0].legend = scrapeListRenderer(renderer, renderer.uniqueValueInfos, window);
  576. break;
  577. case CLASS_BREAKS:
  578. legend.layers[0].legend = scrapeListRenderer(renderer, renderer.classBreakInfos, window);
  579. break;
  580. default:
  581. // FIXME make a basic blank entry (error msg as label?) to prevent things from breaking
  582. // Renderer we dont support
  583. console.error('encountered unsupported renderer legend type: ' + renderer.type);
  584. }
  585. return legend;
  586. };
  587. }
  588. /**
  589. * Returns the legend information of an ESRI map service.
  590. *
  591. * @function getMapServerLegend
  592. * @private
  593. * @param {String} layerUrl service url (root service, not indexed endpoint)
  594. * @param {Object} esriBundle collection of ESRI API objects
  595. * @returns {Promise} resolves in an array of legend data
  596. *
  597. */
  598. function getMapServerLegend(layerUrl, esriBundle) {
  599. // standard json request with error checking
  600. const defService = esriBundle.esriRequest({
  601. url: `${layerUrl}/legend`,
  602. content: { f: 'json' },
  603. callbackParamName: 'callback',
  604. handleAs: 'json',
  605. });
  606. // wrap in promise to contain dojo deferred
  607. return new Promise((resolve, reject) => {
  608. defService.then(srvResult => {
  609. if (srvResult.error) {
  610. reject(srvResult.error);
  611. } else {
  612. resolve(srvResult);
  613. }
  614. }, error => {
  615. reject(error);
  616. });
  617. });
  618. }
  619. /**
  620. * Our symbology engine works off of renderers. When dealing with layers with no renderers,
  621. * we need to take server-side legend and convert it to a fake renderer, which lets us
  622. * leverage all the existing symbology code.
  623. *
  624. * @function mapServerLegendToRenderer
  625. * @private
  626. * @param {Object} serverLegend legend json from an esri map server
  627. * @param {Integer} layerIndex the index of the layer in the legend we are interested in
  628. * @returns {Object} a fake unique value renderer based off the legend
  629. *
  630. */
  631. function mapServerLegendToRenderer(serverLegend, layerIndex) {
  632. const layerLegend = serverLegend.layers.find(l => {
  633. return l.layerId === layerIndex;
  634. });
  635. // make the mock renderer
  636. return {
  637. type: 'uniqueValue',
  638. uniqueValueInfos: layerLegend.legend.map(ll => {
  639. return {
  640. label: ll.label,
  641. symbol: {
  642. type: 'esriPMS',
  643. imageData: ll.imageData,
  644. contentType: ll.contentType
  645. }
  646. };
  647. })
  648. };
  649. }
  650. function buildMapServerToLocalLegend(esriBundle, geoApi) {
  651. /**
  652. * Orchestrator function that will:
  653. * - Fetch a legend from an esri map server
  654. * - Extract legend for a specific sub layer
  655. * - Convert server legend to a temporary renderer
  656. * - Convert temporary renderer to a viewer-formatted legend (return value)
  657. *
  658. * @function mapServerToLocalLegend
  659. * @param {String} mapServerUrl service url (root service, not indexed endpoint)
  660. * @param {Integer} layerIndex the index of the layer in the legend we are interested in
  661. * @returns {Promise} resolves in a viewer-compatible legend for the given server and layer index
  662. *
  663. */
  664. return (mapServerUrl, layerIndex) => {
  665. // get esri legend from server
  666. return getMapServerLegend(mapServerUrl, esriBundle).then(serverLegendData => {
  667. // derive renderer for specified layer
  668. const fakeRenderer = mapServerLegendToRenderer(serverLegendData, layerIndex);
  669. // convert renderer to viewer specific legend
  670. return geoApi.symbology.rendererToLegend(fakeRenderer);
  671. });
  672. };
  673. }
  674. // TODO getZoomLevel should probably live in a file not named symbology
  675. /**
  676. * Takes the lod list and finds level as close to and above scale limit
  677. *
  678. * @param {Array} lods array of esri LODs https://developers.arcgis.com/javascript/jsapi/lod-amd.html
  679. * @param {Integer} maxScale object largest zoom level for said layer
  680. * @returns {Number} current LOD
  681. */
  682. function getZoomLevel(lods, maxScale) {
  683. // Find level as close to and above scaleLimit
  684. const scaleLimit = maxScale; // maxScale obj in returned config
  685. let found = false;
  686. let currentLod = Math.ceil(lods.length / 2);
  687. let lowLod = 0;
  688. let highLod = lods.length - 1;
  689. if (maxScale === 0) {
  690. return lods.length - 1;
  691. }
  692. // Binary Search
  693. while (!found) {
  694. if (lods[currentLod].scale >= scaleLimit) {
  695. lowLod = currentLod;
  696. } else {
  697. highLod = currentLod;
  698. }
  699. currentLod = Math.floor((highLod + lowLod) / 2);
  700. if (highLod === lowLod + 1) {
  701. found = true;
  702. }
  703. }
  704. return currentLod;
  705. }
  706. module.exports = (esriBundle, geoApi, window) => {
  707. return {
  708. getGraphicIcon,
  709. getGraphicSymbol,
  710. rendererToLegend: buildRendererToLegend(window),
  711. generatePlaceholderSymbology,
  712. generateWMSSymbology,
  713. getZoomLevel,
  714. enhanceRenderer,
  715. mapServerToLocalLegend: buildMapServerToLocalLegend(esriBundle, geoApi)
  716. };
  717. };