This demo uses KeyLines to find the origin of a card skimming fraud, which occurs when a skimmer steals a customer's card data during a transaction for later unauthorised use.
The chart shows:
- Nodes representing people and merchants.
- Links representing transactions. Red links are disputed, green are undisputed.
Between the chart and the time bar, there is a number of linked features to help you understand the data:
- Zooming into the time bar's histogram focusses on transactions that happened during the zoomed time period.
- Selecting a person or a merchant foregrounds all their transactions and shows the values as link labels.
- Hovering the histogram shows tooltips with details and pings the relevant nodes in the chart.
- You can use the time bar controls to see how the transactions happened over time.
The featured data is based on Credit Card Fraud Detection scenario created by Neo4j GraphGist.
Key functions used:
- chart.foreground
- chart.ping
- chart.graph().neighbours
- timebar.getIds
- timebar.inRange
- timebar.options().groups
- timebar.selection
- Chart Events
- Time Bar Events
import KeyLines from "keylines";
import {
createChartItems,
blue,
lightRed,
red,
darkRed,
lightGreen,
green,
darkGreen,
white,
black,
setItemHighlight,
getSummaryLinkGlyph,
} from "./data.js";
let components;
let chart;
let timebar;
let aggregateCounter = 1;
let items = createChartItems();
const segmentNames = ["Disputed", "Undisputed"];
const segmentBgColors = [darkRed, darkGreen, red, green];
const segmentTextColors = [white, white, black, black];
const tooltipElements = {
tooltipEl: document.getElementById("tooltip"),
transactionsEl: document.getElementById("tooltip-transactions"),
segmentNameEl: document.getElementById("tooltip-segment-name"),
segmentValueEl: document.getElementById("tooltip-segment-value"),
valueEl: document.getElementById("tooltip-value"),
arrowEl: document.querySelector(".tooltip-arrow"),
innerEl: document.querySelector(".tooltip-inner"),
};
function runLayout() {
return chart.layout("organic", {
time: 500,
straighten: false,
easing: "linear",
mode: "adaptive",
tightness: 4,
});
}
/**
* Animates a halo around the nodes within the selected time bar range.
*/
function pingSelection({ type, index, rangeStart, rangeEnd }) {
if (type !== "bar" && type !== "selection") {
return;
}
const timebarSelection = timebar.selection();
const selection = chart.selection();
let colour = blue;
const ids = timebar.getIds(rangeStart, rangeEnd);
let hoveredIds = [];
if (type === "selection") {
colour = index === 0 ? red : green;
hoveredIds = ids.filter((id) => timebarSelection[index].includes(id));
} else if (type === "bar") {
hoveredIds = ids;
}
// Don't ping nodes that are already selected
if (hoveredIds.length) {
const neighbours = chart.graph().neighbours(hoveredIds);
chart.ping(
neighbours.nodes.filter((node) => !selection.includes(node)),
{ c: colour, time: 1000, w: 30, r: 40, repeat: 1 }
);
}
}
function hideTooltip() {
tooltipElements.tooltipEl.classList.add("fadeout");
}
function showTooltip(hoverEvent) {
// tooltipX/Y is the recommended position to place a tooltip
const { type, value, groupIndex, groupValues, tooltipX, tooltipY, rangeStart, rangeEnd } =
hoverEvent;
const toShow = type === "bar" || type === "selection";
if (toShow) {
const { arrowEl, innerEl, tooltipEl, transactionsEl, valueEl, segmentNameEl, segmentValueEl } =
tooltipElements;
// Change the tooltip content
const transactionCount = timebar.getIds(rangeStart, rangeEnd).length;
transactionsEl.innerHTML = `${transactionCount}`;
valueEl.innerHTML = `$${formatNumber(value)}`;
segmentNameEl.innerHTML = segmentNames[groupIndex % 2];
segmentValueEl.innerHTML = `$${formatNumber(groupValues[groupIndex])}`;
// Style both the tooltip body and the arrow
const backgroundColor = segmentBgColors[groupIndex];
const textColor = segmentTextColors[groupIndex];
arrowEl.style.borderTopColor = backgroundColor;
innerEl.style.backgroundColor = backgroundColor;
innerEl.style.color = textColor;
// The top needs to be adjusted to accommodate the height of the tooltip
const tooltipTop = tooltipY - tooltipEl.offsetHeight;
// Shift left by half width to centre it
const tooltipLeft = tooltipX - tooltipEl.offsetWidth / 2;
// Set the position of the tooltip
tooltipEl.style.left = `${tooltipLeft}px`;
tooltipEl.style.top = `${tooltipTop}px`;
// Show the tooltip
tooltipEl.classList.remove("fadeout");
} else {
hideTooltip();
}
}
function onSelectionChange() {
const selectedIds = chart.selection().filter((id) => chart.getItem(id).type === "node");
if (selectedIds.length === 0) {
// clear the selection
chart.foreground(() => true);
timebar.selection([]);
} else {
const nodesToHighlight = getNodesToHighlight(selectedIds);
chart.foreground((node) => nodesToHighlight.has(node.id));
}
updateLinkState(selectedIds);
}
function updateLinkState(selectedIds) {
const selectedIdsSet = new Set(selectedIds);
const modifiedLinks = [];
chart.each({ type: "link" }, (link) => {
if (link.d.summaryLinkIds) return;
const selected = selectedIdsSet.has(link.id1) || selectedIdsSet.has(link.id2);
const foregrounded = selectedIdsSet.size === 0 || selected;
link.d.status = `${link.d.disputed ? "disputed" : "undisputed"}${foregrounded ? "" : "-bg"}`;
link.d.aggregateId = selected || link.d.userDisaggregated ? ++aggregateCounter : 0;
link.d.selected = selected;
modifiedLinks.push(link);
});
timebar.merge(modifiedLinks);
chart.merge(modifiedLinks);
}
function getNodesToHighlight(selectedIds) {
const nodesToHighlight = new Set(selectedIds);
const undisputedStoreVisitorCounts = new Map();
const selectionKinds = new Set(selectedIds.map((nodeId) => chart.getItem(nodeId).d.label));
// Where only one kind of item is selected, highlight what it links to
if (selectionKinds.size === 1) {
const neighbours = chart.graph().neighbours(selectedIds);
neighbours.nodes.forEach((nodeId) => nodesToHighlight.add(nodeId));
neighbours.links.forEach((linkId) => {
const link = chart.getItem(linkId);
let ends = undisputedStoreVisitorCounts.get(link.id2);
if (ends == null) {
ends = new Set();
undisputedStoreVisitorCounts.set(link.id2, ends);
}
ends.add(link.id1);
});
// Where only people are selected, remove stores without common undisputed transactions
if (selectionKinds.has("person")) {
undisputedStoreVisitorCounts.forEach((linkedNodeIds, id) => {
if (linkedNodeIds.size !== selectedIds.length) {
nodesToHighlight.delete(id);
}
});
}
}
return nodesToHighlight;
}
async function filterChartByRange() {
await chart.filter(timebar.inRange, { type: "link", animate: false });
await runLayout();
onSelectionChange();
}
function formatNumber(num) {
if (num == null) return "";
const fixed = num.toFixed(2);
return fixed.endsWith(".00") ? Math.floor(num).toString() : fixed;
}
function setAggregateLinkStyle(summary) {
const containsDispute = summary.links.some((id) => chart.getItem(id).d.disputed);
const isSelected = summary.links.some((id) => chart.getItem(id).d.selected);
const userDisaggregated = summary.links.some((id) => chart.getItem(id).d.userDisaggregated);
const totalTransactionValue = summary.links.reduce(
(total, id) => total + chart.getItem(id).d.amount,
0
);
chart.setProperties({
id: summary.id,
c: containsDispute ? darkRed : green,
w: 4,
g: getSummaryLinkGlyph(summary, containsDispute),
padding: "3 3 0 3",
fbc: containsDispute ? darkRed : darkGreen,
fc: "white",
t: isSelected ? `$${formatNumber(totalTransactionValue)}` : undefined,
border: {
radius: 8,
},
d: {
summaryLinkIds: summary.links,
userDisaggregated: userDisaggregated,
disputed: containsDispute,
status: containsDispute ? "disputed" : "undisputed",
linkCount: summary.links.length,
},
});
}
function getLink(id) {
const item = chart.getItem(id);
return item != null && item.type === "link" ? item : null;
}
function disaggregateSummaryLink(summaryLink) {
const children = chart.getAggregateInfo(summaryLink.id).aggregateChildren;
for (let i = 0; i < children.length; ++i) {
const childId = children[i];
const item = chart.getItem(childId);
chart.setProperties({
id: children[i],
d: {
...item.d,
aggregateId: ++aggregateCounter,
userDisaggregated: true,
},
});
}
}
function initialiseEventing() {
let prevHoveredItemId = null;
chart.on("hover", ({ id, subItem }) => {
if (prevHoveredItemId !== id && prevHoveredItemId != null) {
setItemHighlight(chart, prevHoveredItemId, false);
}
if (id != null) {
setItemHighlight(chart, id, true);
prevHoveredItemId = id;
}
});
chart.on("link-aggregation", setAggregateLinkStyle);
// Remove present tooltip and filter the chart as the timebar changes
timebar.on("change", () => {
hideTooltip();
filterChartByRange();
});
let pingInterval = null;
// Display tooltip and ping relevant nodes
timebar.on("hover", (hoverEvent) => {
showTooltip(hoverEvent);
// Pings immediately on hovering
pingSelection(hoverEvent);
// Clears any existing intervals to avoid duplicate pings
clearInterval(pingInterval);
// triggers a ping every 2s while hovering
pingInterval = setInterval(() => {
pingSelection(hoverEvent);
}, 2000);
});
// When hover ends stop the pinging and reset interval
timebar.on("hoverEnd", () => {
clearInterval(pingInterval);
pingInterval = null;
});
chart.on("click", ({ preventDefault, id, subItem }) => {
const link = getLink(id);
if (link) {
const glyphClicked = subItem.type === "glyph";
const canBeDisaggregated = link.d.summaryLinkIds?.length > 1 && !link.d.userDisaggregated;
if (glyphClicked && canBeDisaggregated) {
disaggregateSummaryLink(link);
}
// links should not be selectable
preventDefault();
}
});
chart.on("selection-change", onSelectionChange);
// Reset the chart and timebar
document.getElementById("reset").addEventListener("click", async () => {
await chart.merge(items);
await timebar.merge(items);
chart.selection([]);
onSelectionChange();
timebar.zoom("fit", {});
});
document.getElementById("recombine").addEventListener("click", async () => {
const updates = [];
chart.each({ type: "link" }, (link) => {
if (link.d.aggregateId != null) {
updates.push({ id: link.id, d: { ...link.d, userDisaggregated: false } });
}
});
chart.setProperties(updates);
onSelectionChange();
});
// Foreground person nodes with disputed transactions
document.getElementById("select").addEventListener("click", () => {
const toSelect = [];
chart.each({ type: "node" }, (node) => {
if (node.d.hasDisputes && node.d.label === "person") {
toSelect.push(node.id);
}
});
chart.selection(toSelect);
onSelectionChange();
});
}
async function startKeyLines() {
const options = {
hover: 4,
marqueeLinkSelection: "off",
handMode: true,
selectionColour: blue,
selectedNode: {
bw: 0,
ha0: {
c: blue,
r: 20,
w: 20,
},
},
// Use this property to control the amount of alpha blending to apply to background items
backgroundAlpha: 0.25,
imageAlignment: {
// Size and centre icons
"fas fa-shopping-cart": { e: 0.7, dx: -7 },
"fas fa-user": { dy: -7, e: 0.77 },
},
iconFontFamily: "Font Awesome 5 Free",
logo: "/images/Logo.png",
aggregateLinks: { aggregateBy: "aggregateId" },
drag: {
links: false,
},
};
const tbOptions = {
showExtend: true,
playSpeed: 30,
minScale: { units: "day", value: 1 },
groups: {
groupBy: "status",
categories: ["disputed", "undisputed", "disputed-bg", "undisputed-bg"],
colours: [darkRed, green, lightRed, lightGreen],
},
};
components = await KeyLines.create([
{ container: "klchart", type: "chart", options },
{ container: "kltimebar", type: "timebar", options: tbOptions },
]);
chart = components[0];
timebar = components[1];
initialiseEventing();
await chart.load(items);
await timebar.load(items);
onSelectionChange();
await timebar.zoom("fit", {});
await runLayout();
}
async function loadKeyLines() {
await document.fonts.load('24px "Font Awesome 5 Free"');
await startKeyLines();
}
window.addEventListener("DOMContentLoaded", loadKeyLines); export const darkRed = "rgb(180,21,23)";
export const red = "rgb(255,189,190)";
export const lightRed = "rgb(248,232,232)";
export const darkGreen = "rgb(36,130,38)";
export const green = "rgb(154,219,154)";
export const lightGreen = "rgb(235,248,235)";
export const blue = "rgb(50,115,213)";
export const grey = "rgb(99,115,141)";
export const lightGrey = "rgb(200,209,222)";
export const white = "rgb(255,255,255)";
export const black = "rgb(0,0,0)";
const nodeTextStyle = {
position: "s",
borderRadius: 90,
padding: {
top: 3,
left: 6,
right: 6,
},
};
export function createChartItems() {
const items = [];
for (const store of data.stores) {
items.push({
id: store.id,
type: "node",
t: [
{
...nodeTextStyle,
t: store.name,
},
],
c: lightGrey,
fi: {
c: grey,
t: "fas fa-shopping-cart",
},
d: {
label: "merchant",
hasDisputes: store.hasDisputes,
},
});
}
for (const customer of data.customers) {
items.push({
id: customer.id,
type: "node",
c: customer.hasDisputes ? red : green,
t: [
{
...nodeTextStyle,
t: customer.name,
},
],
fi: {
c: customer.hasDisputes ? darkRed : darkGreen,
t: "fas fa-user",
},
d: {
label: "person",
hasDisputes: customer.hasDisputes,
},
});
}
for (const transaction of data.transactions) {
items.push({
id: transaction.id,
type: "link",
id1: transaction.id1,
id2: transaction.id2,
a2: true,
c: transaction.disputed ? red : green,
w: 2,
v: transaction.amount,
d: {
amount: transaction.amount,
time: transaction.time,
disputed: transaction.disputed,
status: transaction.disputed ? "disputed" : "undisputed",
aggregateId: 0,
},
dt: transaction.time,
});
}
return { type: "LinkChart", items: items };
}
export function setItemHighlight(chart, id, highlighted) {
const item = chart.getItem(id);
if (item != null) {
if (item.type === "link" && item.g != null) {
item.g[0].c = highlighted ? blue : item.d.disputed ? darkRed : darkGreen;
} else if (item.d.label === "person") {
item.c = highlighted ? blue : item.d.hasDisputes ? red : green;
item.fi.c = highlighted ? white : item.d.hasDisputes ? darkRed : darkGreen;
} else if (item.d.label === "merchant") {
item.c = highlighted ? blue : lightGrey;
item.fi.c = highlighted ? white : grey;
}
chart.setProperties(item);
}
}
export function getSummaryLinkGlyph(summary, containsDispute) {
return summary.links.length === 1
? undefined
: [
{
p: "ne",
c: containsDispute ? darkRed : darkGreen,
fi: {
c: white,
t: "fa-s fa-expand-alt",
},
border: { colour: containsDispute ? red : green },
},
];
}
const data = {
stores: [
{
id: "10",
name: "Amazon",
},
{
id: "11",
name: "Abercrombie",
},
{
id: "12",
name: "Walmart",
},
{
id: "13",
name: "McDonald's",
},
{
id: "14",
name: "American Apparel",
},
{
id: "15",
name: "Just Brew It",
},
{
id: "16",
name: "Justice",
},
{
id: "17",
name: "Sears",
},
{
id: "18",
name: "Soccer for the City",
},
{
id: "19",
name: "Sprint",
},
{
id: "20",
name: "Starbucks",
},
{
id: "21",
name: "Subway",
},
{
id: "22",
name: "Apple Store",
hasDisputes: true,
},
{
id: "23",
name: "Urban Outfitters",
hasDisputes: true,
},
{
id: "24",
name: "RadioShack",
hasDisputes: true,
},
{
id: "25",
name: "Macy's",
hasDisputes: true,
},
],
customers: [
{
id: "0",
name: "Paul",
hasDisputes: true,
},
{
id: "1",
name: "Jean",
},
{
id: "2",
name: "Dan",
},
{
id: "3",
name: "Marc",
hasDisputes: true,
},
{
id: "4",
name: "John",
},
{
id: "5",
name: "Zoey",
},
{
id: "6",
name: "Ava",
},
{
id: "7",
name: "Olivia",
hasDisputes: true,
},
{
id: "8",
name: "Mia",
},
{
id: "9",
name: "Madison",
hasDisputes: true,
},
],
transactions: [
{
id: "l1",
id1: "0",
id2: "25",
amount: 849,
time: 1419033600000,
disputed: true,
},
{
id: "l2",
id1: "0",
id2: "24",
amount: 415,
time: 1396310400000,
disputed: true,
},
{
id: "l3",
id1: "0",
id2: "23",
amount: 732,
time: 1399680000000,
disputed: true,
},
{
id: "l4",
id1: "0",
id2: "22",
amount: 351.66,
time: 1405641600000,
disputed: true,
},
{
id: "l5",
id1: "0",
id2: "12",
amount: 654,
time: 1395273600000,
},
{
id: "l6",
id1: "0",
id2: "17",
amount: 475,
time: 1395964800000,
},
{
id: "l7",
id1: "0",
id2: "20",
amount: 2.39,
time: 1400112000000,
},
{
id: "l8",
id1: "0",
id2: "15",
amount: 9.86,
time: 1397692800000,
},
{
id: "l9",
id1: "1",
id2: "21",
amount: 2.03,
time: 1395878400000,
},
{
id: "l10",
id1: "1",
id2: "10",
amount: 802,
time: 1394496000000,
},
{
id: "l11",
id1: "1",
id2: "12",
amount: 848,
time: 1401321600000,
},
{
id: "l12",
id1: "1",
id2: "11",
amount: 50.2,
time: 1397001600000,
},
{
id: "l13",
id1: "1",
id2: "18",
amount: 196,
time: 1406160000000,
},
{
id: "l14",
id1: "2",
id2: "10",
amount: 141,
time: 1415923200000,
},
{
id: "l15",
id1: "2",
id2: "18",
amount: 62,
time: 1410912000000,
},
{
id: "l16",
id1: "2",
id2: "13",
amount: 6.05,
time: 1390780800000,
},
{
id: "l17",
id1: "2",
id2: "13",
amount: 3.5,
time: 1390435200000,
},
{
id: "l18",
id1: "3",
id2: "25",
amount: 1003,
time: 1419033600000,
disputed: true,
},
{
id: "l19",
id1: "3",
id2: "24",
amount: 1121,
time: 1396310400000,
disputed: true,
},
{
id: "l20",
id1: "3",
id2: "23",
amount: 424,
time: 1399680000000,
disputed: true,
},
{
id: "l21",
id1: "3",
id2: "22",
amount: 814,
time: 1405641600000,
disputed: true,
},
{
id: "l22",
id1: "3",
id2: "18",
amount: 11,
time: 1409788800000,
},
{
id: "l23",
id1: "3",
id2: "17",
amount: 430,
time: 1407628800000,
},
{
id: "l24",
id1: "3",
id2: "12",
amount: 964,
time: 1395446400000,
},
{
id: "l25",
id1: "3",
id2: "14",
amount: 33.6,
time: 1396483200000,
},
{
id: "l26",
id1: "3",
id2: "10",
amount: 134,
time: 1397433600000,
},
{
id: "l27",
id1: "4",
id2: "15",
amount: 9.21,
time: 1394582400000,
},
{
id: "l28",
id1: "4",
id2: "14",
amount: 76.8,
time: 1417132800000,
},
{
id: "l29",
id1: "4",
id2: "16",
amount: 468,
time: 1406592000000,
},
{
id: "l30",
id1: "4",
id2: "19",
amount: 457,
time: 1413331200000,
},
{
id: "l31",
id1: "4",
id2: "18",
amount: 545,
time: 1412553600000,
},
{
id: "l32",
id1: "5",
id2: "21",
amount: 3.53,
time: 1414195200000,
},
{
id: "l33",
id1: "5",
id2: "10",
amount: 721,
time: 1405555200000,
},
{
id: "l34",
id1: "5",
id2: "15",
amount: 4.14,
time: 1390176000000,
},
{
id: "l35",
id1: "5",
id2: "11",
amount: 51.0,
time: 1417046400000,
},
{
id: "l36",
id1: "5",
id2: "13",
amount: 7.4,
time: 1418601600000,
},
{
id: "l37",
id1: "6",
id2: "15",
amount: 6.27,
time: 1400544000000,
},
{
id: "l38",
id1: "6",
id2: "14",
amount: 72.3,
time: 1389139200000,
},
{
id: "l39",
id1: "6",
id2: "14",
amount: 53.3,
time: 1407283200000,
},
{
id: "l40",
id1: "6",
id2: "12",
amount: 87,
time: 1392768000000,
},
{
id: "l41",
id1: "6",
id2: "17",
amount: 681,
time: 1419724800000,
},
{
id: "l42",
id1: "7",
id2: "25",
amount: 790,
time: 1419033600000,
disputed: true,
},
{
id: "l43",
id1: "7",
id2: "24",
amount: 884,
time: 1406851200000,
disputed: true,
},
{
id: "l44",
id1: "7",
id2: "23",
amount: 1152,
time: 1407628800000,
disputed: true,
},
{
id: "l45",
id1: "7",
id2: "22",
amount: 1149,
time: 1405641600000,
disputed: true,
},
{
id: "l46",
id1: "7",
id2: "15",
amount: 7.42,
time: 1407801600000,
},
{
id: "l47",
id1: "7",
id2: "18",
amount: 924,
time: 1412380800000,
},
{
id: "l48",
id1: "7",
id2: "12",
amount: 231,
time: 1405123200000,
},
{
id: "l49",
id1: "7",
id2: "18",
amount: 74,
time: 1409788800000,
},
{
id: "l50",
id1: "8",
id2: "18",
amount: 164,
time: 1419552000000,
},
{
id: "l51",
id1: "8",
id2: "10",
amount: 240,
time: 1404864000000,
},
{
id: "l52",
id1: "8",
id2: "17",
amount: 830,
time: 1394668800000,
},
{
id: "l53",
id1: "8",
id2: "13",
amount: 4.67,
time: 1419292800000,
},
{
id: "l54",
id1: "8",
id2: "20",
amount: 6.6,
time: 1397606400000,
},
{
id: "l55",
id1: "8",
id2: "18",
amount: 276,
time: 1419379200000,
},
{
id: "l56",
id1: "9",
id2: "25",
amount: 816,
time: 1419033600000,
disputed: true,
},
{
id: "l57",
id1: "9",
id2: "24",
amount: 368,
time: 1404172800000,
disputed: true,
},
{
id: "l58",
id1: "9",
id2: "23",
amount: 374,
time: 1404950400000,
disputed: true,
},
{
id: "l59",
id1: "9",
id2: "22",
amount: 925,
time: 1405641600000,
disputed: true,
},
{
id: "l60",
id1: "9",
id2: "12",
amount: 91,
time: 1404000000000,
},
{
id: "l61",
id1: "9",
id2: "10",
amount: 147,
time: 1407024000000,
},
{
id: "l62",
id1: "9",
id2: "21",
amount: 3.52,
time: 1418688000000,
},
{
id: "l63",
id1: "9",
id2: "11",
amount: 19,
time: 1406592000000,
},
{
id: "l64",
id1: "9",
id2: "13",
amount: 4.99,
time: 1412553600000,
},
{
id: "l65",
id1: "2",
id2: "13",
amount: 5.32,
time: 1390348800000,
},
{
id: "l66",
id1: "2",
id2: "13",
amount: 7.99,
time: 1390435200000 - 86400000 * 2,
},
{
id: "l67",
id1: "9",
id2: "13",
amount: 4.99,
time: 1390262400000,
},
{
id: "l68",
id1: "9",
id2: "13",
amount: 4.99,
time: 1412726400000,
},
{
id: "l69",
id1: "9",
id2: "21",
amount: 3.52,
time: 1418774400000,
},
{
id: "l70",
id1: "9",
id2: "21",
amount: 3.52,
time: 1418860800000,
},
],
}; <!doctype html>
<html lang="en" style="background-color: #2d383f">
<head>
<meta charset="utf-8" />
<title>Credit Card Fraud</title>
<link rel="stylesheet" type="text/css" href="@ci/theme/kl/css/keylines.css" />
<link rel="stylesheet" type="text/css" href="@ci/theme/kl/css/minimalsdk.css" />
<link rel="stylesheet" type="text/css" href="@ci/theme/kl/css/sdk-layout.css" />
<link rel="stylesheet" type="text/css" href="@ci/theme/kl/css/demo.css" />
<link
rel="stylesheet"
type="text/css"
href="@fortawesome/[email protected]/css/fontawesome.css"
/>
<link
rel="stylesheet"
type="text/css"
href="@fortawesome/[email protected]/css/solid.css"
/>
<link rel="stylesheet" type="text/css" href="creditcard.css" />
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<div id="klchart" class="klchart klchart-timebar"></div>
<div id="kltimebar" class="kltimebar"></div>
<script type="module" src="./code.js"></script>
</body>
</html> #tooltip .tooltip-arrow {
position: absolute;
width: 0;
height: 0;
border-color: transparent;
border-style: solid;
}
.tooltip-arrow-down {
bottom: 0;
left: 50%;
margin-left: -5px;
border-top-color: #000;
border-width: 5px 5px 0;
}
#tooltip {
top: 200px;
position: absolute;
pointer-events: none;
display: block;
float: left;
color: white;
font-size: 12px;
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
transition: opacity 0.3s ease;
}
#tooltip tr {
height: 20px;
}
#tooltip.fadeout {
opacity: 0;
}