swatches.js 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. import {pathRound as path} from "d3";
  2. import {inferFontVariant} from "../axes.js";
  3. import {create, createContext} from "../context.js";
  4. import {isNoneish, maybeColorChannel, maybeNumberChannel} from "../options.js";
  5. import {isOrdinalScale, isThresholdScale} from "../scales.js";
  6. import {applyInlineStyles, impliedString, maybeClassName} from "../style.js";
  7. import {inferTickFormat} from "../marks/axis.js";
  8. function maybeScale(scale, key) {
  9. if (key == null) return key;
  10. const s = scale(key);
  11. if (!s) throw new Error(`scale not found: ${key}`);
  12. return s;
  13. }
  14. export function legendSwatches(color, {opacity, ...options} = {}) {
  15. if (!isOrdinalScale(color) && !isThresholdScale(color))
  16. throw new Error(`swatches legend requires ordinal or threshold color scale (not ${color.type})`);
  17. return legendItems(color, options, (selection, scale, width, height) =>
  18. selection
  19. .append("svg")
  20. .attr("width", width)
  21. .attr("height", height)
  22. .attr("fill", scale.scale)
  23. .attr("fill-opacity", maybeNumberChannel(opacity)[1])
  24. .append("rect")
  25. .attr("width", "100%")
  26. .attr("height", "100%")
  27. );
  28. }
  29. export function legendSymbols(
  30. symbol,
  31. {
  32. fill = symbol.hint?.fill !== undefined ? symbol.hint.fill : "none",
  33. fillOpacity = 1,
  34. stroke = symbol.hint?.stroke !== undefined ? symbol.hint.stroke : isNoneish(fill) ? "currentColor" : "none",
  35. strokeOpacity = 1,
  36. strokeWidth = 1.5,
  37. r = 4.5,
  38. ...options
  39. } = {},
  40. scale
  41. ) {
  42. const [vf, cf] = maybeColorChannel(fill);
  43. const [vs, cs] = maybeColorChannel(stroke);
  44. const sf = maybeScale(scale, vf);
  45. const ss = maybeScale(scale, vs);
  46. const size = r * r * Math.PI;
  47. fillOpacity = maybeNumberChannel(fillOpacity)[1];
  48. strokeOpacity = maybeNumberChannel(strokeOpacity)[1];
  49. strokeWidth = maybeNumberChannel(strokeWidth)[1];
  50. return legendItems(symbol, options, (selection, scale, width, height) =>
  51. selection
  52. .append("svg")
  53. .attr("viewBox", "-8 -8 16 16")
  54. .attr("width", width)
  55. .attr("height", height)
  56. .attr("fill", vf === "color" ? (d) => sf.scale(d) : cf)
  57. .attr("fill-opacity", fillOpacity)
  58. .attr("stroke", vs === "color" ? (d) => ss.scale(d) : cs)
  59. .attr("stroke-opacity", strokeOpacity)
  60. .attr("stroke-width", strokeWidth)
  61. .append("path")
  62. .attr("d", (d) => {
  63. const p = path();
  64. symbol.scale(d).draw(p, size);
  65. return p;
  66. })
  67. );
  68. }
  69. function legendItems(scale, options = {}, swatch) {
  70. let {
  71. columns,
  72. tickFormat,
  73. fontVariant = inferFontVariant(scale),
  74. // TODO label,
  75. swatchSize = 15,
  76. swatchWidth = swatchSize,
  77. swatchHeight = swatchSize,
  78. marginLeft = 0,
  79. className,
  80. style,
  81. width
  82. } = options;
  83. const context = createContext(options);
  84. className = maybeClassName(className);
  85. tickFormat = inferTickFormat(scale.scale, scale.domain, undefined, tickFormat);
  86. const swatches = create("div", context).attr(
  87. "class",
  88. `${className}-swatches ${className}-swatches-${columns != null ? "columns" : "wrap"}`
  89. );
  90. let extraStyle;
  91. if (columns != null) {
  92. extraStyle = `:where(.${className}-swatches-columns .${className}-swatch) {
  93. display: flex;
  94. align-items: center;
  95. break-inside: avoid;
  96. padding-bottom: 1px;
  97. }
  98. :where(.${className}-swatches-columns .${className}-swatch::before) {
  99. flex-shrink: 0;
  100. }
  101. :where(.${className}-swatches-columns .${className}-swatch-label) {
  102. white-space: nowrap;
  103. overflow: hidden;
  104. text-overflow: ellipsis;
  105. }`;
  106. swatches
  107. .style("columns", columns)
  108. .selectAll()
  109. .data(scale.domain)
  110. .enter()
  111. .append("div")
  112. .attr("class", `${className}-swatch`)
  113. .call(swatch, scale, swatchWidth, swatchHeight)
  114. .call((item) =>
  115. item.append("div").attr("class", `${className}-swatch-label`).attr("title", tickFormat).text(tickFormat)
  116. );
  117. } else {
  118. extraStyle = `:where(.${className}-swatches-wrap) {
  119. display: flex;
  120. align-items: center;
  121. min-height: 33px;
  122. flex-wrap: wrap;
  123. }
  124. :where(.${className}-swatches-wrap .${className}-swatch) {
  125. display: inline-flex;
  126. align-items: center;
  127. margin-right: 1em;
  128. }`;
  129. swatches
  130. .selectAll()
  131. .data(scale.domain)
  132. .enter()
  133. .append("span")
  134. .attr("class", `${className}-swatch`)
  135. .call(swatch, scale, swatchWidth, swatchHeight)
  136. .append(function () {
  137. return this.ownerDocument.createTextNode(tickFormat.apply(this, arguments));
  138. });
  139. }
  140. return swatches
  141. .call((div) =>
  142. div.insert("style", "*").text(
  143. `:where(.${className}-swatches) {
  144. font-family: system-ui, sans-serif;
  145. font-size: 10px;
  146. margin-bottom: 0.5em;
  147. }
  148. :where(.${className}-swatch > svg) {
  149. margin-right: 0.5em;
  150. overflow: visible;
  151. }
  152. ${extraStyle}`
  153. )
  154. )
  155. .style("margin-left", marginLeft ? `${+marginLeft}px` : null)
  156. .style("width", width === undefined ? null : `${+width}px`)
  157. .style("font-variant", impliedString(fontVariant, "normal"))
  158. .call(applyInlineStyles, style)
  159. .node();
  160. }