Bug 1153477 - Add a button to GC markers when allocations are on to snapto all objects allocated from this button to the previous one in theallocations call tree. r=vp,fitzgen
authorJordan Santell <jsantell@mozilla.com>
Thu, 20 Aug 2015 13:10:47 -0700
changeset 293720 87ed59529e01fcf1b695c24949ef3dc41d8b5195
parent 293719 4bbecd76b761941b0745b36740d442b4abec3af5
child 293721 920a9f7d4932292e697eb3b66c85f7d6fdf9d574
push id962
push userjlund@mozilla.com
push dateFri, 04 Dec 2015 23:28:54 +0000
treeherdermozilla-release@23a2d286e80f [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersvp, fitzgen
bugs1153477
milestone43.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1153477 - Add a button to GC markers when allocations are on to snapto all objects allocated from this button to the previous one in theallocations call tree. r=vp,fitzgen
browser/app/profile/firefox.js
browser/devtools/performance/modules/global.js
browser/devtools/performance/modules/logic/marker-utils.js
browser/devtools/performance/modules/widgets/marker-details.js
browser/devtools/performance/test/browser.ini
browser/devtools/performance/test/browser_perf-details-waterfall-gc-snap-01.js
browser/devtools/performance/test/browser_perf-details-waterfall-gc-snap.js
browser/devtools/performance/test/head.js
browser/devtools/performance/views/details-waterfall.js
browser/devtools/shared/options-view.js
browser/themes/shared/devtools/performance.css
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -1440,16 +1440,18 @@ pref("devtools.performance.ui.invert-cal
 pref("devtools.performance.ui.invert-flame-graph", false);
 pref("devtools.performance.ui.flatten-tree-recursion", true);
 pref("devtools.performance.ui.show-platform-data", false);
 pref("devtools.performance.ui.show-idle-blocks", true);
 pref("devtools.performance.ui.enable-memory", false);
 pref("devtools.performance.ui.enable-allocations", false);
 pref("devtools.performance.ui.enable-framerate", true);
 pref("devtools.performance.ui.enable-jit-optimizations", false);
+pref("devtools.performance.ui.show-triggers-for-gc-types",
+  "TOO_MUCH_MALLOC ALLOC_TRIGGER LAST_DITCH EAGER_ALLOC_TRIGGER");
 
 // Temporary pref disabling memory flame views
 // TODO remove once we have flame charts via bug 1148663
 pref("devtools.performance.ui.enable-memory-flame", false);
 
 // Enable experimental options in the UI only in Nightly
 #if defined(NIGHTLY_BUILD)
 pref("devtools.performance.ui.experimental", true);
--- a/browser/devtools/performance/modules/global.js
+++ b/browser/devtools/performance/modules/global.js
@@ -13,16 +13,17 @@ const L10N = new ViewHelpers.MultiL10N([
   "chrome://browser/locale/devtools/performance.properties"
 ]);
 
 /**
  * A list of preferences for this tool. The values automatically update
  * if somebody edits edits about:config or the prefs change somewhere else.
  */
 const PREFS = new ViewHelpers.Prefs("devtools.performance", {
+  "show-triggers-for-gc-types": ["Char", "ui.show-triggers-for-gc-types"],
   "show-platform-data": ["Bool", "ui.show-platform-data"],
   "hidden-markers": ["Json", "timeline.hidden-markers"],
   "memory-sample-probability": ["Float", "memory.sample-probability"],
   "memory-max-log-length": ["Int", "memory.max-log-length"],
   "profiler-buffer-size": ["Int", "profiler.buffer-size"],
   "profiler-sample-frequency": ["Int", "profiler.sample-frequency-khz"],
   // TODO re-enable once we flame charts via bug 1148663
   "enable-memory-flame": ["Bool", "ui.enable-memory-flame"],
--- a/browser/devtools/performance/modules/logic/marker-utils.js
+++ b/browser/devtools/performance/modules/logic/marker-utils.js
@@ -284,17 +284,47 @@ const DOM = {
         frameIndex = frame.asyncParent;
         wasAsyncParent = true;
       } else {
         frameIndex = frame.parent;
       }
     }
 
     return container;
-  }
+  },
+
+  /**
+   * Builds any custom fields specific to the marker.
+   *
+   * @param {Document} doc
+   * @param {ProfileTimelineMarker} marker
+   * @param {object} options
+   * @return {Array<Element>}
+   */
+  buildCustom: function (doc, marker, options) {
+    let elements = [];
+
+    if (options.allocations && showAllocationsTrigger(marker)) {
+      let hbox = doc.createElement("hbox");
+      hbox.className = "marker-details-customcontainer";
+
+      let label = doc.createElement("label");
+      label.className = "custom-button devtools-button";
+      label.setAttribute("value", "Show allocation triggers");
+      label.setAttribute("type", "show-allocations");
+      label.setAttribute("data-action", JSON.stringify({
+        endTime: marker.start, action: "show-allocations"
+      }));
+
+      hbox.appendChild(label);
+      elements.push(hbox);
+    }
+
+    return elements;
+  },
 };
 
 /**
  * Mapping of JS marker causes to a friendlier form. Only
  * markers that are considered "from content" should be labeled here.
  */
 const JS_MARKER_MAP = {
   "<script> element":          L10N.getStr("marker.label.javascript.scriptElement"),
@@ -414,15 +444,27 @@ const Formatters = {
  *
  * @param {Marker} marker
  * @return {object}
  */
 function getBlueprintFor (marker) {
   return TIMELINE_BLUEPRINT[marker.name] || TIMELINE_BLUEPRINT.UNKNOWN;
 }
 
+/**
+ * Takes a marker and determines if this marker should display
+ * the allocations trigger button.
+ *
+ * @param {Marker} marker
+ * @return {boolean}
+ */
+function showAllocationsTrigger (marker) {
+  return marker.name === "GarbageCollection" &&
+         PREFS["show-triggers-for-gc-types"].split(" ").indexOf(marker.causeName) !== -1;
+}
+
 exports.isMarkerValid = isMarkerValid;
 exports.getMarkerLabel = getMarkerLabel;
 exports.getMarkerClassName = getMarkerClassName;
 exports.getMarkerFields = getMarkerFields;
 exports.DOM = DOM;
 exports.Formatters = Formatters;
 exports.getBlueprintFor = getBlueprintFor;
--- a/browser/devtools/performance/modules/widgets/marker-details.js
+++ b/browser/devtools/performance/modules/widgets/marker-details.js
@@ -83,24 +83,27 @@ MarkerDetails.prototype = {
 
   /**
    * Populates view with marker's details.
    *
    * @param object params
    *        An options object holding:
    *          - marker: The marker to display.
    *          - frames: Array of stack frame information; see stack.js.
+   *          - allocations: Whether or not allocations were enabled for this recording. [optional]
    */
-  render: function({ marker, frames }) {
+  render: function (options) {
+    let { marker, frames } = options;
     this.empty();
 
     let elements = [];
     elements.push(MarkerUtils.DOM.buildTitle(this._document, marker));
     elements.push(MarkerUtils.DOM.buildDuration(this._document, marker));
     MarkerUtils.DOM.buildFields(this._document, marker).forEach(f => elements.push(f));
+    MarkerUtils.DOM.buildCustom(this._document, marker, options).forEach(f => elements.push(f));
 
     // Build a stack element -- and use the "startStack" label if
     // we have both a startStack and endStack.
     if (marker.stack) {
       let type = marker.endStack ? "startStack" : "stack";
       elements.push(MarkerUtils.DOM.buildStackTrace(this._document, {
         frameIndex: marker.stack, frames, type
       }));
@@ -121,19 +124,17 @@ MarkerDetails.prototype = {
    * for the moment.
    */
   _onClick: function (e) {
     let data = findActionFromEvent(e.target, this._parent);
     if (!data) {
       return;
     }
 
-    if (data.action === "view-source") {
-      this.emit("view-source", data.url, data.line);
-    }
+    this.emit(data.action, data);
   },
 
   /**
    * Handles the "mouseup" event on the marker details view splitter.
    */
   _onSplitterMouseUp: function() {
     this.emit("resize");
   }
--- a/browser/devtools/performance/test/browser.ini
+++ b/browser/devtools/performance/test/browser.ini
@@ -25,16 +25,18 @@ support-files =
 [browser_perf-console-record-06.js]
 [browser_perf-console-record-07.js]
 [browser_perf-console-record-08.js]
 [browser_perf-console-record-09.js]
 [browser_perf-details-calltree-render.js]
 [browser_perf-details-flamegraph-render.js]
 [browser_perf-details-memory-calltree-render.js]
 [browser_perf-details-memory-flamegraph-render.js]
+[browser_perf-details-waterfall-gc-snap.js]
+skip-if = true # Bug 1161817
 [browser_perf-details-waterfall-render.js]
 [browser_perf-details-01.js]
 [browser_perf-details-02.js]
 [browser_perf-details-03.js]
 [browser_perf-details-04.js]
 [browser_perf-details-05.js]
 [browser_perf-details-06.js]
 [browser_perf-details-07.js]
rename from browser/devtools/performance/test/browser_perf-details-waterfall-gc-snap-01.js
rename to browser/devtools/performance/test/browser_perf-details-waterfall-gc-snap.js
--- a/browser/devtools/performance/test/browser_perf-details-waterfall-gc-snap-01.js
+++ b/browser/devtools/performance/test/browser_perf-details-waterfall-gc-snap.js
@@ -1,40 +1,138 @@
 /* Any copyright is dedicated to the Public Domain.
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
 /**
  * Tests that the waterfall view renders content after recording.
  */
 function* spawnTest() {
   let { panel } = yield initPerformance(SIMPLE_URL);
-  let { EVENTS, PerformanceController, DetailsView, WaterfallView } = panel.panelWin;
+  let { $, $$, EVENTS, PerformanceController, OverviewView, DetailsView, WaterfallView, MemoryCallTreeView } = panel.panelWin;
+  let EPSILON = 0.00001;
+
+  // Add the Cu.forceGC option to display allocation types for tests
+  let triggerTypes = Services.prefs.getCharPref("devtools.performance.ui.show-triggers-for-gc-types");
+  Services.prefs.setCharPref("devtools.performance.ui.show-triggers-for-gc-types", `${triggerTypes} COMPONENT_UTILS`);
 
-  Services.prefs.setBoolPref(ALLOCATIONS_PREF, false);
+  // Disable non GC markers so we don't have nested markers and marker index
+  // matches DOM index.
+  PerformanceController.setPref("hidden-markers", Array.reduce($$("menuitem"), (disabled, item) => {
+    let type = item.getAttribute("marker-type");
+    if (type && type !== "GarbageCollection") {
+      disabled.push(type);
+    }
+    return disabled;
+  }, []));
+
+  Services.prefs.setBoolPref(ALLOCATIONS_PREF, true);
+
   yield startRecording(panel);
   yield waitUntil(hasGCMarkers(PerformanceController));
-
   let rendered = once(WaterfallView, EVENTS.WATERFALL_RENDERED);
   yield stopRecording(panel);
   ok(DetailsView.isViewSelected(WaterfallView),
     "The waterfall view is selected by default in the details view.");
   yield rendered;
 
+  let bars = $$(".waterfall-marker-bar");
+  let markers = PerformanceController.getCurrentRecording().getMarkers();
+  let gcMarkers = markers.filter(isForceGCMarker);
+
+  ok(gcMarkers.length >= 2, "should have atleast 2 GC markers");
+  ok(bars.length >= 2, "should have atleast 2 GC markers rendered");
+
+  info("Received markers:");
+  for (let marker of gcMarkers) {
+    info(`${marker.causeName} ${marker.start}:${marker.end}`);
+  }
+
+  /**
+   * Check when it's the first GC marker
+   */
+
+  info(`Will show allocation trigger button for ${Services.prefs.getCharPref("devtools.performance.ui.show-triggers-for-gc-types")}`);
+  info(`Clicking GC Marker of type ${gcMarkers[0].causeName} ${gcMarkers[0].start}:${gcMarkers[0].end}`);
+  EventUtils.sendMouseEvent({ type: "mousedown" }, bars[0]);
+  let showAllocsButton;
+  // On slower machines this can not be found immediately?
+  yield waitUntil(() => showAllocsButton = $("#waterfall-details .custom-button[type='show-allocations']"));
+  ok(showAllocsButton, "GC buttons when allocations are enabled");
+
+  rendered = once(MemoryCallTreeView, EVENTS.MEMORY_CALL_TREE_RENDERED);
+  EventUtils.sendMouseEvent({ type: "click" }, showAllocsButton);
+  yield rendered;
+
+  is(OverviewView.getTimeInterval().startTime, 0, "When clicking first GC, should use 0 as start time");
+  within(OverviewView.getTimeInterval().endTime, gcMarkers[0].start, EPSILON, "Correct end time range");
+
+  let duration = PerformanceController.getCurrentRecording().getDuration();
+  rendered = once(WaterfallView, EVENTS.WATERFALL_RENDERED);
+  OverviewView.setTimeInterval({ startTime: 0, endTime: duration });
+  yield DetailsView.selectView("waterfall");
+  yield rendered;
+
+  /**
+   * Check when there is a previous GC marker
+   */
+
+  bars = $$(".waterfall-marker-bar");
+  info(`Clicking GC Marker of type ${gcMarkers[1].causeName} ${gcMarkers[1].start}:${gcMarkers[1].end}`);
+  EventUtils.sendMouseEvent({ type: "mousedown" }, bars[1]);
+  // On slower machines this can not be found immediately?
+  yield waitUntil(() => showAllocsButton = $("#waterfall-details .custom-button[type='show-allocations']"));
+  ok(showAllocsButton, "GC buttons when allocations are enabled");
+
+  rendered = once(MemoryCallTreeView, EVENTS.MEMORY_CALL_TREE_RENDERED);
+  EventUtils.sendMouseEvent({ type: "click" }, showAllocsButton);
+  yield rendered;
+
+  within(OverviewView.getTimeInterval().startTime, gcMarkers[0].end, EPSILON,
+    "selection start range is previous GC marker's end time");
+  within(OverviewView.getTimeInterval().endTime, gcMarkers[1].start, EPSILON,
+    "selection end range is current GC marker's start time");
+
+  /**
+   * Now with allocations disabled
+   */
+
+  // Reselect the entire recording -- due to bug 1196945, the new recording
+  // won't reset the selection
+  duration = PerformanceController.getCurrentRecording().getDuration();
+  rendered = once(WaterfallView, EVENTS.WATERFALL_RENDERED);
+  OverviewView.setTimeInterval({ startTime: 0, endTime: duration });
+  yield rendered;
 
   Services.prefs.setBoolPref(ALLOCATIONS_PREF, false);
   yield startRecording(panel);
-  yield waitUntil(() => PerformanceController.getCurrentRecording().getMarkers().length);
+  yield waitUntil(hasGCMarkers(PerformanceController));
 
   rendered = once(WaterfallView, EVENTS.WATERFALL_RENDERED);
   yield stopRecording(panel);
+  ok(DetailsView.isViewSelected(WaterfallView),
+    "The waterfall view is selected by default in the details view.");
   yield rendered;
 
-  ok(true, "WaterfallView rendered again after recording completed a second time.");
+  ok(true, "WaterfallView rendered after recording is stopped.");
+
+  bars = $$(".waterfall-marker-bar");
+  markers = PerformanceController.getCurrentRecording().getMarkers();
+  gcMarkers = markers.filter(isForceGCMarker);
+
+  EventUtils.sendMouseEvent({ type: "mousedown" }, bars[0]);
+  showAllocsButton = $("#waterfall-details .custom-button[type='show-allocations']");
+  ok(!showAllocsButton, "No GC buttons when allocations are disabled");
+
 
   yield teardown(panel);
   finish();
 }
 
+function isForceGCMarker (m) {
+  return m.name === "GarbageCollection" && m.causeName === "COMPONENT_UTILS" && m.start >= 0;
+}
+
 function hasGCMarkers (controller) {
   return function () {
-    controller.getCurrentRecording().getMarkers().filter(m => m.name === "GarbageCollection").length >== 2;
+    Cu.forceGC();
+    return controller.getCurrentRecording().getMarkers().filter(isForceGCMarker).length >= 2;
   };
 }
--- a/browser/devtools/performance/test/head.js
+++ b/browser/devtools/performance/test/head.js
@@ -51,16 +51,17 @@ const EXPERIMENTAL_PREF = "devtools.perf
 waitForExplicitFinish();
 
 DevToolsUtils.testing = true;
 
 let DEFAULT_PREFS = [
   "devtools.debugger.log",
   "devtools.performance.ui.invert-call-tree",
   "devtools.performance.ui.flatten-tree-recursion",
+  "devtools.performance.ui.show-triggers-for-gc-types",
   "devtools.performance.ui.show-platform-data",
   "devtools.performance.ui.show-idle-blocks",
   "devtools.performance.ui.enable-memory",
   "devtools.performance.ui.enable-allocations",
   "devtools.performance.ui.enable-framerate",
   "devtools.performance.ui.enable-jit-optimizations",
   "devtools.performance.memory.sample-probability",
   "devtools.performance.memory.max-log-length",
@@ -544,8 +545,12 @@ function synthesizeProfileForTest(sample
     samples: samples,
     markers: []
   }, uniqueStacks);
 }
 
 function isVisible (element) {
   return !element.classList.contains("hidden") && !element.hidden;
 }
+
+function within (actual, expected, fuzz, desc) {
+  ok((actual - expected) <= fuzz, `${desc}: Expected ${actual} to be within ${fuzz} of ${expected}`);
+}
--- a/browser/devtools/performance/views/details-waterfall.js
+++ b/browser/devtools/performance/views/details-waterfall.js
@@ -6,16 +6,19 @@
 const WATERFALL_RESIZE_EVENTS_DRAIN = 100; // ms
 const MARKER_DETAILS_WIDTH = 200;
 
 /**
  * Waterfall view containing the timeline markers, controlled by DetailsView.
  */
 let WaterfallView = Heritage.extend(DetailsSubview, {
 
+  // Smallest unit of time between two markers. Larger by 10x^3 than Number.EPSILON.
+  MARKER_EPSILON: 0.000000000001,
+
   observedPrefs: [
     "hidden-markers"
   ],
 
   rerenderPrefs: [
     "hidden-markers"
   ],
 
@@ -27,44 +30,47 @@ let WaterfallView = Heritage.extend(Deta
   initialize: function () {
     DetailsSubview.initialize.call(this);
 
     this._cache = new WeakMap();
 
     this._onMarkerSelected = this._onMarkerSelected.bind(this);
     this._onResize = this._onResize.bind(this);
     this._onViewSource = this._onViewSource.bind(this);
+    this._onShowAllocations = this._onShowAllocations.bind(this);
     this._hiddenMarkers = PerformanceController.getPref("hidden-markers");
 
     this.headerContainer = $("#waterfall-header");
     this.breakdownContainer = $("#waterfall-breakdown");
     this.detailsContainer = $("#waterfall-details");
     this.detailsSplitter = $("#waterfall-view > splitter");
 
     this.details = new MarkerDetails($("#waterfall-details"), $("#waterfall-view > splitter"));
     this.details.hidden = true;
 
     this.details.on("resize", this._onResize);
     this.details.on("view-source", this._onViewSource);
+    this.details.on("show-allocations", this._onShowAllocations);
     window.addEventListener("resize", this._onResize);
 
     // TODO bug 1167093 save the previously set width, and ensure minimum width
     this.details.width = MARKER_DETAILS_WIDTH;
   },
 
   /**
    * Unbinds events.
    */
   destroy: function () {
     DetailsSubview.destroy.call(this);
 
     this._cache = null;
 
     this.details.off("resize", this._onResize);
     this.details.off("view-source", this._onViewSource);
+    this.details.off("show-allocations", this._onShowAllocations);
     window.removeEventListener("resize", this._onResize);
   },
 
   /**
    * Method for handling all the set up for rendering a new waterfall.
    *
    * @param object interval [optional]
    *        The { startTime, endTime }, in milliseconds.
@@ -82,19 +88,20 @@ let WaterfallView = Heritage.extend(Deta
 
   /**
    * Called when a marker is selected in the waterfall view,
    * updating the markers detail view.
    */
   _onMarkerSelected: function (event, marker) {
     let recording = PerformanceController.getCurrentRecording();
     let frames = recording.getFrames();
+    let allocations = recording.getConfiguration().withAllocations;
 
     if (event === "selected") {
-      this.details.render({ toolbox: gToolbox, marker, frames });
+      this.details.render({ marker, frames, allocations });
       this.details.hidden = false;
       this._lastSelected = marker;
     }
     if (event === "unselected") {
       this.details.empty();
     }
   },
 
@@ -117,18 +124,53 @@ let WaterfallView = Heritage.extend(Deta
     // Clear the cache as we'll need to recompute the collapsed
     // marker model
     this._cache = new WeakMap();
   },
 
   /**
    * Called when MarkerDetails view emits an event to view source.
    */
-  _onViewSource: function (_, file, line) {
-    gToolbox.viewSourceInDebugger(file, line);
+  _onViewSource: function (_, data) {
+    gToolbox.viewSourceInDebugger(data.file, data.line);
+  },
+
+  /**
+   * Called when MarkerDetails view emits an event to snap to allocations.
+   */
+  _onShowAllocations: function (_, data) {
+    let { endTime } = data;
+    let startTime = 0;
+    let recording = PerformanceController.getCurrentRecording();
+    let markers = recording.getMarkers();
+
+    let mostRecentGC = null;
+
+    // Iterate over markers looking for the most recent GC marker
+    // before the one who's start time is `endTime`.
+    for (let marker of markers) {
+      // We found the marker whose allocations we're tracking; abort
+      if (marker.start === endTime) {
+        break;
+      }
+      if (marker.name === "GarbageCollection") {
+        mostRecentGC = marker;
+      }
+    }
+
+    if (mostRecentGC) {
+      startTime = mostRecentGC.end;
+    }
+
+    // Adjust times so we don't include the range of these markers themselves.
+    endTime -= this.MARKER_EPSILON;
+    startTime += startTime !== 0 ? this.MARKER_EPSILON : 0;
+
+    OverviewView.setTimeInterval({ startTime, endTime });
+    DetailsView.selectView("memory-calltree");
   },
 
   /**
    * Called when the recording is stopped and prepares data to
    * populate the waterfall tree.
    */
   _prepareWaterfallTree: function(markers) {
     let cached = this._cache.get(markers);
--- a/browser/devtools/shared/options-view.js
+++ b/browser/devtools/shared/options-view.js
@@ -1,11 +1,11 @@
 const EventEmitter = require("devtools/toolkit/event-emitter");
 const { Services } = require("resource://gre/modules/Services.jsm");
-
+const { Preferences } = require("resource://gre/modules/Preferences.jsm");
 const OPTIONS_SHOWN_EVENT = "options-shown";
 const OPTIONS_HIDDEN_EVENT = "options-hidden";
 const PREF_CHANGE_EVENT = "pref-changed";
 
 /**
  * OptionsView constructor. Takes several options, all required:
  * - branchName: The name of the prefs branch, like "devtools.debugger."
  * - menupopup: The XUL `menupopup` item that contains the pref buttons.
@@ -157,24 +157,24 @@ const PrefObserver = function (branchNam
 };
 
 PrefObserver.prototype = {
   /**
    * Returns `prefName`'s value. Does not require the branch name.
    */
   get: function (prefName) {
     let fullName = this.branchName + prefName;
-    return Services.prefs.getBoolPref(fullName);
+    return Preferences.get(fullName);
   },
   /**
    * Sets `prefName`'s `value`. Does not require the branch name.
    */
   set: function (prefName, value) {
     let fullName = this.branchName + prefName;
-    Services.prefs.setBoolPref(fullName, value);
+    Preferences.set(fullName, value);
   },
   register: function () {
     this.branch.addObserver("", this, false);
   },
   unregister: function () {
     this.branch.removeObserver("", this);
   },
   observe: function (subject, topic, prefName) {
--- a/browser/themes/shared/devtools/performance.css
+++ b/browser/themes/shared/devtools/performance.css
@@ -523,16 +523,21 @@
   font-size: 1.2em;
   font-weight: bold;
 }
 
 .marker-details-duration {
   font-weight: bold;
 }
 
+.marker-details-customcontainer .custom-button {
+  padding: 2px 5px;
+  border-width: 1px;
+}
+
 /**
  * Marker colors
  */
 
 menuitem.marker-color-graphs-purple:before,
 .marker-color-graphs-purple {
   background-color: var(--theme-graphs-purple);
 }