Compare commits

..

5 Commits

Author SHA1 Message Date
Ladd Hoffman 7388a15cff Improvements to graph rendering 2023-08-09 19:19:36 -05:00
Ladd Hoffman 4026d7eaa8 Draw arrowhead and equalize distances 2023-08-08 18:38:49 -05:00
Ladd Hoffman 2195f5ea56 Force-directed graph is working 2023-08-08 12:08:57 -05:00
Ladd Hoffman ec3401845d Further WIP 2023-08-03 18:23:21 -05:00
Ladd Hoffman 435633a893 WIP 2023-08-02 14:54:31 -05:00
42 changed files with 1841 additions and 522 deletions

17
notes/reputation-types.md Normal file
View File

@ -0,0 +1,17 @@
Reputation is comprised of non-fungible tokens associated with specific forum graph post -> author edges.
Therefore in principle, all information about the context of a given rep token can be derived by inspecting the forum graph.
However, in practice, the computational costs and the difficulty of preserving complete records will increase over time.
It is for this reason that we compute the current value of a given rep token and store that value.
Although the value could be recomputed when needed, it would be (unpredictably) expensive and time-consuming to do so.
In its current, singular form, all instances of reputation within a given DAO have equal power, assuming equal numeric value.
However, the question arises: what would it take to support the ability to initiate a validation pool in which the power of a reputation token
depends on something more than just its numeric value?
This would be something specified when a validation pool is initiated.
Suppose we support the notion of distinct types of reputation within a given DAO.
Let's say we have reputation type A and B.
Let's say we have a validation pool that requires reputation type A to vote, and mints reputation type A.
That means governance is separated.

View File

@ -25,7 +25,7 @@ export class Expert extends ReputationHolder {
return 0;
}
const authorEdges = authorVertex.getEdges(EdgeTypes.AUTHOR, false);
const tokenValues = authorEdges.map(({ data: { tokenId } }) => this.dao.reputation.valueOf(tokenId));
const tokenValues = authorEdges.map(({ data: { tokenAddress } }) => this.dao.reputation.valueOf(tokenAddress));
return tokenValues.reduce((value, total) => total += value, 0);
}
@ -42,7 +42,7 @@ export class Expert extends ReputationHolder {
await this.actions.submitPost.log(this, post);
const postId = post.id;
const pool = await this.initiateValidationPool({ fee, postId }, params);
this.tokens.push(pool.tokenId);
this.tokens.push(pool.tokenAddress);
return { postId, pool };
}
@ -53,7 +53,7 @@ export class Expert extends ReputationHolder {
postId,
fee,
}, params);
this.tokens.push(pool.tokenId);
this.tokens.push(pool.tokenAddress);
return pool;
}
@ -68,7 +68,7 @@ export class Expert extends ReputationHolder {
`(${position ? 'for' : 'against'}, stake: ${amount})`,
);
return validationPool.stake(this.reputationPublicKey, {
position, amount, lockingTime, tokenId: this.tokens[0],
position, amount, lockingTime, tokenAddress: this.tokens[0],
});
}
@ -80,7 +80,7 @@ export class Expert extends ReputationHolder {
);
this.workerId = await this.dao.availability.register(this.reputationPublicKey, {
stakeAmount,
tokenId: this.tokens[0],
tokenAddress: this.tokens[0],
duration,
});
}

View File

@ -3,9 +3,9 @@ import { Actor } from '../display/actor.js';
import { CryptoUtil } from '../supporting/crypto.js';
class Worker {
constructor(reputationPublicKey, tokenId, stakeAmount, duration) {
constructor(reputationPublicKey, tokenAddress, stakeAmount, duration) {
this.reputationPublicKey = reputationPublicKey;
this.tokenId = tokenId;
this.tokenAddress = tokenAddress;
this.stakeAmount = stakeAmount;
this.duration = duration;
this.available = true;
@ -28,11 +28,11 @@ export class Availability extends Actor {
this.workers = new Map();
}
register(reputationPublicKey, { stakeAmount, tokenId, duration }) {
register(reputationPublicKey, { stakeAmount, tokenAddress, duration }) {
// TODO: Should be signed by token owner
this.dao.reputation.lock(tokenId, stakeAmount, duration);
this.dao.reputation.lock(tokenAddress, stakeAmount, duration);
const workerId = CryptoUtil.randomUUID();
this.workers.set(workerId, new Worker(reputationPublicKey, tokenId, stakeAmount, duration));
this.workers.set(workerId, new Worker(reputationPublicKey, tokenAddress, stakeAmount, duration));
return workerId;
}

View File

@ -87,7 +87,7 @@ export class Business extends Actor {
});
await pool.stake(reputationPublicKey, {
tokenId: request.worker.tokenId,
tokenAddress: request.worker.tokenAddress,
amount: request.worker.stakeAmount,
position: true,
});

View File

@ -5,6 +5,7 @@ import { Availability } from './availability.js';
import { Business } from './business.js';
import { Voter } from '../supporting/voter.js';
import { Actor } from '../display/actor.js';
import { DEFAULT_REP_TOKEN_TYPE_ID } from '../../util/constants.js';
/**
* Purpose:
@ -49,15 +50,13 @@ export class DAO extends Actor {
});
}
getActiveReputation() {
/**
* @param {number} param0.reputationTypeId
* @returns {number}
*/
getActiveAvailableReputation({ reputationTypeId = DEFAULT_REP_TOKEN_TYPE_ID }) {
return this.listActiveVoters()
.map(({ reputationPublicKey }) => this.reputation.valueOwnedBy(reputationPublicKey))
.reduce((acc, cur) => (acc += cur), 0);
}
getActiveAvailableReputation() {
return this.listActiveVoters()
.map(({ reputationPublicKey }) => this.reputation.availableValueOwnedBy(reputationPublicKey))
.map(({ reputationPublicKey }) => this.reputation.availableValueOwnedBy(reputationPublicKey, reputationTypeId))
.reduce((acc, cur) => (acc += cur), 0);
}

View File

@ -1,4 +1,3 @@
import { WeightedDirectedGraph } from '../supporting/wdg.js';
import { Action } from '../display/action.js';
import { Actor } from '../display/actor.js';
import { ReputationHolder } from '../reputation/reputation-holder.js';
@ -6,6 +5,7 @@ import { displayNumber } from '../../util/helpers.js';
import {
EPSILON, INCINERATOR_ADDRESS, EdgeTypes, VertexTypes,
} from '../../util/constants.js';
import { WDGDiagram } from '../display/wdg-mermaid-ui.js';
class Post extends Actor {
constructor(forum, senderId, postContent) {
@ -15,8 +15,8 @@ class Post extends Actor {
this.forum = forum;
this.id = postContent.id ?? name;
this.senderId = senderId;
this.value = 0;
this.initialValue = 0;
this.values = new Map();
this.initialValues = new Map();
this.authors = postContent.authors;
this.citations = postContent.citations;
this.title = postContent.title;
@ -44,7 +44,7 @@ export class Forum extends ReputationHolder {
super(name, scene);
this.dao = dao;
this.id = this.reputationPublicKey;
this.graph = new WeightedDirectedGraph('forum', scene);
this.graph = new WDGDiagram('forum', scene);
this.actions = {
propagate: new Action('propagate', scene),
confirm: new Action('confirm', scene),
@ -83,16 +83,21 @@ export class Forum extends ReputationHolder {
// getContract(type) { }
async onValidate({
pool, postId, tokenId, referenceChainLimit, leachingValue,
pool, postId, tokenAddress, referenceChainLimit, leachingValue,
}) {
console.log('onValidate', { pool, postId, tokenId });
const initialValue = this.dao.reputation.valueOf(tokenId);
console.log('onValidate', { pool, postId, tokenAddress });
// What we have here now is an ERC-1155 rep token, which can contain multiple reputation types.
// TODO: ERC-1155 supports a batch transfer operation, so it probably makes sense to leverage that.
const initialValues = new Map(pool.reputationTypeIds
.map((tokenTypeId) => [tokenTypeId, this.dao.reputation.valueOf(tokenAddress, tokenTypeId)]));
const postVertex = this.graph.getVertex(postId);
const post = postVertex.data;
post.setStatus('Validated');
post.initialValue = initialValue;
post.initialValues = initialValues;
const addAuthorToGraph = (publicKey, weight, authorTokenId) => {
const addAuthorToGraph = (publicKey, weight, authorTokenAddress) => {
// For graph display purposes, we want to use the existing Expert actors from the current scene.
const author = this.scene.findActor(({ reputationPublicKey }) => reputationPublicKey === publicKey);
author.setDisplayValue('reputation', () => author.getReputation());
@ -105,7 +110,7 @@ export class Forum extends ReputationHolder {
postVertex,
authorVertex,
weight,
{ tokenId: authorTokenId },
{ tokenAddress: authorTokenAddress },
{ hide: author.options.hide },
);
};
@ -114,16 +119,18 @@ export class Forum extends ReputationHolder {
// If no authors are specified, treat the sender as the sole author.
// TODO: Verify that cumulative author weight == 1.
if (!post.authors?.length) {
addAuthorToGraph(post.senderId, 1, tokenId);
addAuthorToGraph(post.senderId, 1, tokenAddress);
} else {
for (const { publicKey, weight } of post.authors) {
// If the sender is also listed among the authors, do not mint them an additional token.
const authorTokenId = (publicKey === post.senderId) ? tokenId : this.dao.reputation.mint(this.id, 0);
addAuthorToGraph(publicKey, weight, authorTokenId);
const authorTokenAddress = (publicKey === post.senderId)
? tokenAddress
: this.dao.reputation.mintBatch(this.id, pool.reputationTypeIds, pool.reputationTypeIds.map(() => 0));
addAuthorToGraph(publicKey, weight, authorTokenAddress);
}
// If the sender is not an author, they will end up with the minted token but with zero value.
if (!post.authors.find(({ publicKey }) => publicKey === post.senderId)) {
addAuthorToGraph(post.senderId, 0, tokenId);
addAuthorToGraph(post.senderId, 0, tokenAddress);
}
}
@ -134,7 +141,7 @@ export class Forum extends ReputationHolder {
{ to: postVertex, from: { data: pool } },
{
rewardsAccumulator,
increment: initialValue,
increments: initialValues,
referenceChainLimit,
leachingValue,
},
@ -142,16 +149,16 @@ export class Forum extends ReputationHolder {
// Apply computed rewards to update values of tokens
for (const [authorEdge, amount] of rewardsAccumulator) {
const { to: authorVertex, data: { tokenId: authorTokenId } } = authorEdge;
const { to: authorVertex, data: { tokenAddress: authorTokenAddress } } = authorEdge;
const { data: author } = authorVertex;
// The primary author gets the validation pool minted token.
// So we don't need to transfer any reputation to the primary author.
// Their reward will be the remaining balance after all other transfers.
if (authorTokenId !== tokenId) {
if (authorTokenAddress !== tokenAddress) {
if (amount < 0) {
this.dao.reputation.transferValueFrom(authorTokenId, tokenId, -amount);
this.dao.reputation.transferValueFrom(authorTokenAddress, tokenAddress, -amount);
} else {
this.dao.reputation.transferValueFrom(tokenId, authorTokenId, amount);
this.dao.reputation.transferValueFrom(tokenAddress, authorTokenAddress, amount);
}
await author.computeDisplayValues((label, value) => authorVertex.setProperty(label, value));
authorVertex.displayVertex();
@ -167,8 +174,8 @@ export class Forum extends ReputationHolder {
for (const authorEdge of postVertex.getEdges(EdgeTypes.AUTHOR, true)) {
const authorVertex = authorEdge.to;
const author = authorVertex.data;
const { tokenId: authorTokenId } = authorEdge.data;
this.dao.reputation.transfer(this.id, author.reputationPublicKey, authorTokenId);
const { tokenAddress: authorTokenAddress } = authorEdge.data;
this.dao.reputation.transfer(this.id, author.reputationPublicKey, authorTokenAddress);
}
}
@ -178,7 +185,7 @@ export class Forum extends ReputationHolder {
*/
async propagateValue(edge, {
rewardsAccumulator,
increment,
increments,
depth = 0,
initialNegative = false,
referenceChainLimit,
@ -186,7 +193,9 @@ export class Forum extends ReputationHolder {
}) {
const postVertex = edge.to;
const post = postVertex.data;
this.actions.propagate.log(edge.from.data, post, `(${increment})`);
const incrementsStrArray = Array.from(increments.entries()).map(([k, v]) => `${k} ${v}`);
const incrementsStr = `(${incrementsStrArray.join(')(')})`;
this.actions.propagate.log(edge.from.data, post, incrementsStr);
if (!!referenceChainLimit && depth > referenceChainLimit) {
this.actions.propagate.log(
@ -196,25 +205,28 @@ export class Forum extends ReputationHolder {
null,
'-x',
);
return increment;
return increments;
}
console.log('propagateValue start', {
from: edge.from.id ?? edge.from,
to: edge.to.id,
depth,
value: post.value,
increment,
values: post.values,
increments,
initialNegative,
});
const propagate = async (positive) => {
let totalOutboundAmount = 0;
const totalOutboundAmounts = increments.map(() => 0);
const citationEdges = postVertex.getEdges(EdgeTypes.CITATION, true)
.filter(({ weight }) => (positive ? weight > 0 : weight < 0));
for (const citationEdge of citationEdges) {
const { weight } = citationEdge;
let outboundAmount = weight * increment;
const outboundAmounts = increments.map((increment) => weight * increment);
const refundsFromOutbound = increments.map(() => 0);
for (let idx = 0; idx < outboundAmounts.length; idx++) {
let outboundAmount = outboundAmounts[idx];
if (Math.abs(outboundAmount) > EPSILON) {
const balanceToOutbound = this.graph.getEdgeWeight(EdgeTypes.BALANCE, citationEdge.from, citationEdge.to)
?? 0;
@ -228,7 +240,7 @@ export class Forum extends ReputationHolder {
this.actions.propagate.log(
citationEdge.from.data,
{ name: 'Incinerator' },
`(${increment})`,
incrementsStr,
undefined,
'-x',
);
@ -236,7 +248,7 @@ export class Forum extends ReputationHolder {
}
// Reputation sent to the incinerator is burned! This means it is deducted from the sender,
// without increasing the value of any other token.
this.actions.propagate.log(citationEdge.from.data, { name: 'Incinerator' }, `(${increment})`);
this.actions.propagate.log(citationEdge.from.data, { name: 'Incinerator' }, incrementsStr);
} else {
// We need to ensure that we at most undo the prior effects of this post
if (initialNegative) {
@ -269,32 +281,38 @@ export class Forum extends ReputationHolder {
citationEdge.to,
balanceToOutbound + outboundAmount,
);
totalOutboundAmount += outboundAmount;
refundsFromOutbound[idx] = refundFromOutbound;
totalOutboundAmounts[idx] += outboundAmount;
}
const refundStr = refundsFromOutbound.map((refund) => displayNumber(refund)).join('/');
this.actions.confirm.log(
citationEdge.to.data,
citationEdge.from.data,
`(refund: ${displayNumber(refundFromOutbound)}, leach: ${outboundAmount * leachingValue})`,
`(refund: ${refundStr}, leach: ${outboundAmount * leachingValue})`,
undefined,
'-->>',
);
}
}
return totalOutboundAmount;
return totalOutboundAmounts;
};
// First, leach value via negative citations
const totalLeachingAmount = await propagate(false);
increment -= totalLeachingAmount * leachingValue;
const totalLeachingAmounts = await propagate(false);
for (let idx = 0; idx < totalLeachingAmounts.length; idx++) {
increments[idx] -= totalLeachingAmounts[idx] * leachingValue;
}
// Now propagate value via positive citations
const totalDonationAmount = await propagate(true);
increment -= totalDonationAmount * leachingValue;
const totalDonationAmounts = await propagate(true);
for (let idx = 0; idx < totalDonationAmounts.length; idx++) {
increments[idx] -= totalDonationAmounts[idx] * leachingValue;
}
// Apply the remaining increment to the present post
const rawNewValue = post.value + increment;
const newValue = Math.max(0, rawNewValue);
const appliedIncrement = newValue - post.value;
const rawNewValues = post.values.map((value, idx) => value + increments[idx]);
const newValues = rawNewValues.map((rawNewValue) => Math.max(0, rawNewValue));
const appliedIncrements = newValue - post.value;
const refundToInbound = increment - appliedIncrement;
// Apply reputation effects to post authors, not to the post directly

View File

@ -2,6 +2,7 @@ import { ReputationHolder } from '../reputation/reputation-holder.js';
import { Stake } from '../supporting/stake.js';
import { Action } from '../display/action.js';
import { displayNumber } from '../../util/helpers.js';
import { DEFAULT_REP_TOKEN_TYPE_ID } from '../../util/constants.js';
const params = {
/* Validation Pool parameters */
@ -51,6 +52,7 @@ export class ValidationPool extends ReputationHolder {
duration,
tokenLossRatio,
contentiousDebate = false,
reputationTypes,
},
name,
scene,
@ -69,6 +71,16 @@ export class ValidationPool extends ReputationHolder {
this.actions.initiate.log(fromActor, this, `(fee: ${fee})`);
this.activate();
// Supporting a simplified use case, if the reputation type is not specified let's use a default
this.reputationTypes = reputationTypes ?? [{ reputationTypeId: DEFAULT_REP_TOKEN_TYPE_ID, weight: 1 }];
// Normalize so reputation weights sum to 1
{
const weightTotal = this.reputationTypes.reduce((total, { weight }) => total += weight, 0);
for (const reputationType of this.reputationTypes) {
reputationType.weight /= weightTotal;
}
}
// If contentiousDebate = true, we will follow the progression defined by getTokenLossRatio()
if (
!contentiousDebate
@ -130,22 +142,34 @@ export class ValidationPool extends ReputationHolder {
this.duration = duration;
this.tokenLossRatio = tokenLossRatio;
this.contentiousDebate = contentiousDebate;
this.mintedValue = fee * params.mintingRatio();
this.tokenId = this.dao.reputation.mint(this.id, this.mintedValue);
// Tokens minted "for" the post go toward stake of author voting for their own post.
// Also, author can provide additional stakes, e.g. availability stakes for work evidence post.
const mintTotal = fee * params.mintingRatio();
const reputationTypeIds = this.reputationTypes
.map(({ reputationTypeId }) => reputationTypeId);
const mintValues = this.reputationTypes
.map(({ weight }) => mintTotal * weight);
console.log('validation pool constructor', { reputationTypeIds, mintValues });
this.tokenAddress = this.dao.reputation.mintBatch(this.id, reputationTypeIds, mintValues);
this.reputationTypeIds = reputationTypeIds;
// Minted tokens are staked for/against the post at configured ratio
// Each type of reputation is staked in the proportions specified by the `reputationTypes` parameter
for (const { reputationTypeId, weight } of this.reputationTypes) {
this.stake(this.id, {
position: true,
amount: this.mintedValue * params.stakeForAuthor,
tokenId: this.tokenId,
amount: mintTotal * params.stakeForAuthor * weight,
tokenAddress: this.tokenAddress,
reputationTypeId,
});
this.stake(this.id, {
position: false,
amount: this.mintedValue * (1 - params.stakeForAuthor),
tokenId: this.tokenId,
amount: this.mintedValue * (1 - params.stakeForAuthor) * weight,
tokenAddress: this.tokenAddress,
reputationTypeId,
});
}
this.actions.mint.log(this, this, `(${this.mintedValue})`);
this.actions.mint.log(this, this, `(${mintTotal})`);
// Keep a record of voters and their votes
this.dao.addVoteRecord(reputationPublicKey, this);
@ -174,39 +198,38 @@ export class ValidationPool extends ReputationHolder {
}
/**
* @param {boolean} outcome: null --> all entries. Otherwise filters to position === outcome.
* @param {boolean} options.outcome: null --> all entries. Otherwise filters to position === outcome.
* @param {boolean} options.tokenTypeId: null --> all entries. Otherwise filters to the given token type.
* @param {boolean} options.excludeSystem: Whether to exclude votes cast during pool initialization
* @returns stake[]
*/
getStakes(outcome, { excludeSystem }) {
getStakes({ outcome, tokenTypeId, excludeSystem }) {
return Array.from(this.stakes.values())
.filter(({ tokenId }) => !excludeSystem || tokenId !== this.tokenId)
.filter((stake) => tokenTypeId === null || stake.tokenTypeId === tokenTypeId)
.filter(({ tokenAddress }) => !excludeSystem || tokenAddress !== this.tokenAddress)
.filter(({ position }) => outcome === null || position === outcome);
}
/**
* @param {boolean} outcome: null --> all entries. Otherwise filters to position === outcome.
* @param {boolean} options.outcome: null --> all entries. Otherwise filters to position === outcome.
* @returns number
*/
getTotalStakedOnPost(outcome) {
return this.getStakes(outcome, { excludeSystem: false })
.map((stake) => stake.getStakeValue({ lockingTimeExponent: params.lockingTimeExponent }))
.reduce((acc, cur) => (acc += cur), 0);
getStakedAmount({ outcome, tokenTypeId }) {
return this.getStakes({ outcome, tokenTypeId, excludeSystem: false })
.map((stake) => stake.getAmount({ lockingTimeExponent: params.lockingTimeExponent }))
.reduce((total, amount) => (total += amount), 0);
}
/**
* @param {boolean} outcome: null --> all entries. Otherwise filters to position === outcome.
* @returns number
* Stake reputation in favor of a given outcome for this validation pool.
*
* @param {*} reputationPublicKey
* @param {object} opts
*/
getTotalValueOfStakesForOutcome(outcome) {
return this.getStakes(outcome, { excludeSystem: false })
.reduce((total, { amount }) => (total += amount), 0);
}
// TODO: This can be handled as a hook on receipt of reputation token transfer
async stake(reputationPublicKey, {
tokenId, position, amount, lockingTime = 0,
tokenAddress, tokenTypeId = DEFAULT_REP_TOKEN_TYPE_ID, position, amount, lockingTime = 0,
}) {
// TODO: This can be handled as a hook on receipt of reputation token transfer
if (this.state === ValidationPoolStates.CLOSED) {
throw new Error(`Validation pool ${this.id} is closed.`);
}
@ -217,17 +240,17 @@ export class ValidationPool extends ReputationHolder {
);
}
if (reputationPublicKey !== this.dao.reputation.ownerOf(tokenId)) {
if (reputationPublicKey !== this.dao.reputation.ownerOf(tokenAddress)) {
throw new Error('Reputation may only be staked by its owner!');
}
const stake = new Stake({
tokenId, position, amount, lockingTime,
tokenAddress, tokenTypeId, position, amount, lockingTime,
});
this.stakes.add(stake);
// Transfer staked amount from the sender to the validation pool
this.dao.reputation.transferValueFrom(tokenId, this.tokenId, amount);
this.dao.reputation.transferValueFrom(tokenAddress, this.tokenAddress, tokenTypeId, amount);
// Keep a record of voters and their votes
if (reputationPublicKey !== this.id) {
@ -243,8 +266,10 @@ export class ValidationPool extends ReputationHolder {
// Before evaluating the winning conditions,
// we need to make sure any staked tokens are locked for the
// specified amounts of time.
for (const { tokenId, amount, lockingTime } of this.stakes.values()) {
this.dao.reputation.lock(tokenId, amount, lockingTime);
for (const {
tokenAddress, tokenTypeId, amount, lockingTime,
} of this.stakes.values()) {
this.dao.reputation.lock(tokenAddress, tokenTypeId, amount, lockingTime);
// TODO: If there is an exception here, the voter may have voted incorrectly. Consider penalties.
}
}
@ -261,23 +286,33 @@ export class ValidationPool extends ReputationHolder {
this.state = ValidationPoolStates.CLOSED;
this.setStatus('Closed');
const upvoteValue = this.getTotalValueOfStakesForOutcome(true);
const downvoteValue = this.getTotalValueOfStakesForOutcome(false);
const activeAvailableReputation = this.dao.getActiveAvailableReputation();
const votePasses = upvoteValue >= params.winningRatio * downvoteValue;
// Votes should be scaled by weights of this.reputationTypes
const upvoteValue = this.reputationTypes.reduce((value, { reputationTypeId, weight }) => {
value += this.getStakedAmount({ outcome: true, reputationTypeId }) * weight;
return value;
}, 0);
const downvoteValue = this.reputationTypes.reduce((value, { reputationTypeId, weight }) => {
value += this.getStakedAmount({ outcome: false, reputationTypeId }) * weight;
return value;
}, 0);
const activeAvailableReputation = this.reputationTypes.reduce((value, { reputationTypeId, weight }) => {
value += this.dao.getActiveAvailableReputation({ reputationTypeId }) * weight;
return value;
}, 0);
const outcome = upvoteValue >= params.winningRatio * downvoteValue;
const quorumMet = upvoteValue + downvoteValue >= params.quorum * activeAvailableReputation;
const result = {
votePasses,
outcome,
upvoteValue,
downvoteValue,
};
if (quorumMet) {
this.setStatus(`Resolved - ${votePasses ? 'Won' : 'Lost'}`);
this.scene?.sequence.log(`note over ${this.name} : ${votePasses ? 'Win' : 'Lose'}`);
this.setStatus(`Resolved - ${outcome ? 'Won' : 'Lost'}`);
this.scene?.sequence.log(`note over ${this.name} : ${outcome ? 'Win' : 'Lose'}`);
this.applyTokenLocking();
await this.distributeReputation({ votePasses });
await this.distributeReputation({ outcome });
// TODO: distribute fees
} else {
this.setStatus('Resolved - Quorum not met');
@ -301,47 +336,57 @@ export class ValidationPool extends ReputationHolder {
return result;
}
async distributeReputation({ votePasses }) {
// For now we assume a tightly binding pool, where all staked reputation is lost
// TODO: Take tokenLossRatio into account
// TODO: revoke staked reputation from losing voters
// In a tightly binding validation pool, losing voter stakes are transferred to winning voters.
const tokensForWinners = this.getTotalStakedOnPost(!votePasses);
const winningEntries = this.getStakes(votePasses, { excludeSystem: true });
const totalValueOfStakesForWin = this.getTotalValueOfStakesForOutcome(votePasses);
async distributeReputation({ outcome }) {
// In a binding validation pool, losing voter stakes are transferred to winning voters.
// TODO: Regression tests for different tokenLossRatio values
const tokenLossRatio = this.getTokenLossRatio();
for (const { reputationTypeId, weight } of this.reputationTypes) {
const tokensForWinners = this.getStakedAmount({ outcome: !outcome, reputationTypeId }) * weight * tokenLossRatio;
const winningEntries = this.getStakes({ outcome, reputationTypeId, excludeSystem: true });
const totalValueOfStakesForWin = this.getStakedAmount({ outcome, reputationTypeId });
// Compute rewards for the winning voters, in proportion to the value of their stakes.
for (const stake of winningEntries) {
const { tokenId, amount } = stake;
const value = stake.getStakeValue({ lockingTimeExponent: params.lockingTimeExponent });
const { tokenAddress, amount } = stake;
const value = stake.getAmount({ lockingTimeExponent: params.lockingTimeExponent });
const reward = tokensForWinners * (value / totalValueOfStakesForWin);
// Also return each winning voter their staked amount
const reputationPublicKey = this.dao.reputation.ownerOf(tokenId);
console.log(`reward for winning stake by ${reputationPublicKey}: ${reward}`);
this.dao.reputation.transferValueFrom(this.tokenId, tokenId, reward + amount);
const reputationPublicKey = this.dao.reputation.ownerOf(tokenAddress);
console.log(`reward of type ${reputationTypeId} for winning stake by ${reputationPublicKey}: ${reward}`);
this.dao.reputation.transferValueFrom(this.tokenAddress, tokenAddress, reputationTypeId, reward + amount);
const toActor = this.scene?.findActor((actor) => actor.reputationPublicKey === reputationPublicKey);
this.actions.reward.log(this, toActor, `(${displayNumber(reward)})`);
this.actions.reward.log(this, toActor, `(${displayNumber(reward)} type ${reputationTypeId})`);
}
}
if (votePasses) {
if (outcome === true) {
// Distribute awards to author via the forum
// const tokensForAuthor = this.mintedValue * params.stakeForAuthor + rewards.get(this.tokenId);
console.log(`sending reward for author stake to forum: ${this.dao.reputation.valueOf(this.tokenId)}`);
const tokens = this.reputationTypes.reduce((values, { reputationTypeId }) => {
const value = this.dao.reputation.valueOf(this.tokenAddress, reputationTypeId) ?? 0;
values[reputationTypeId] = value;
return values;
}, {});
console.log('sending reward for author stake to forum', tokens);
// Transfer ownership of the minted token, from the pool to the forum
this.dao.reputation.transfer(this.id, this.dao.forum.id, this.tokenId);
// const value = this.dao.reputation.valueOf(this.tokenId);
this.dao.reputation.transfer(this.id, this.dao.forum.id, this.tokenAddress);
// const value = this.dao.reputation.valueOf(this.tokenAddress);
// this.actions.transfer.log(this, this.dao.forum, `(${value})`);
// Recurse through forum to determine reputation effects
await this.dao.forum.onValidate({
const result = {
pool: this,
postId: this.postId,
tokenId: this.tokenId,
tokenAddress: this.tokenAddress,
referenceChainLimit: params.referenceChainLimit,
leachingValue: params.leachingValue,
});
};
// Recurse through forum to determine reputation effects
await this.dao.forum.onValidate({ ...result });
if (this.onValidate) {
await this.onValidate({ ...result });
}
}
console.log('pool complete');

View File

@ -89,6 +89,9 @@ export class Actor {
async computeDisplayValues(cb) {
for (const [label, fn] of this.valueFunctions.entries()) {
const value = fn();
console.log('computeDisplay', {
label, value, fn, cb,
});
await this.setDisplayValue(label, value);
if (cb) {
cb(label, value);

View File

@ -1,5 +1,6 @@
import { DisplayValue } from './display-value.js';
import { randomID } from '../../util/helpers.js';
import { Rectangle, Vector } from '../supporting/geometry/index.js';
export class Box {
constructor(name, parentEl, options = {}) {
@ -20,6 +21,7 @@ export class Box {
parentEl.appendChild(this.el);
}
}
this.position = options.position ?? Vector.zeros(2);
}
flex({ center = false } = {}) {
@ -35,11 +37,6 @@ export class Box {
return this;
}
hidden() {
this.addClass('hidden');
return this;
}
addClass(className) {
this.el.classList.add(className);
return this;
@ -63,4 +60,19 @@ export class Box {
getId() {
return this.el.id;
}
get rect() {
const {
width, height,
} = this.el.getBoundingClientRect();
return new Rectangle(this.position, [width, height]);
}
move(vector) {
vector = vector instanceof Vector ? vector : Vector.from(vector);
this.position = this.position.add(vector);
this.el.style.left = `${Math.floor(this.position[0])}px`;
this.el.style.top = `${Math.floor(this.position[1])}px`;
// this.el.dispatchEvent(new CustomEvent('moved'));
}
}

View File

@ -37,7 +37,6 @@ export class Document extends Box {
}
get lastElement() {
if (!this.elements.length) return null;
return this.elements[this.elements.length - 1];
}
}

View File

@ -0,0 +1,354 @@
import {
DEFAULT_TIME_STEP,
MINIMUM_VELOCITY,
VISCOSITY_FACTOR,
TIME_DILATION_FACTOR,
MAXIMUM_STEPS,
TRANSLATION_VELOCITY_FACTOR,
ARROWHEAD_LENGTH,
ARROWHEAD_WIDTH,
MINIMUM_STEPS,
CENTRAL_RESTORING_FORCE,
} from '../../util/constants.js';
import { Edge } from '../supporting/edge.js';
import { WeightedDirectedGraph } from '../supporting/wdg.js';
import { Box } from './box.js';
import { Vector, Rectangle } from '../supporting/geometry/index.js';
import { overlapRepulsionForce, targetRadiusForce } from './pairwise-forces.js';
import { Vertex } from '../supporting/vertex.js';
// Render children with absolute css positioning.
// Let there be a force between elements such that the force between
// any two elements is along the line between their centers,
// so that the elements repel when too close but attract when too far.
// The equilibrium distance can be tuned, e.g. can be scaled by an input.
// NOTE: (with optional overlay preferring a grid or some other shape?),
// NOTE: Could also allow user input dragging elements.
// What might be neat here is to implement a force-based resistance effect;
// basically, the mouse pointer drags the element with a spring rather than directly.
// If the shape of the graph resists the transformation,
// the distance between the element and the cursor should increase.
// On an interval, compute forces among the elements.
// Simulate the effects of these forces
// NOTE: Impart random nudges, and resolve their effects to a user-visible resolution
// before rendering.
// NOTE: When mouse is in our box, we could hijack the scroll actions to zoom in/out.
export class ForceDirectedGraph extends WeightedDirectedGraph {
constructor(name, parentEl, { width = 800, height = 600, ...options } = {}) {
super(name, options);
this.box = new Box(name, parentEl, options);
this.box.addClass('fixed');
this.box.addClass('force-directed-graph');
this.intervalTask = null;
this.canvas = window.document.createElement('canvas');
this.width = width;
this.height = height;
this.box.el.style.width = `${width}px`;
this.box.el.style.height = `${height}px`;
this.box.el.appendChild(this.canvas);
this.fitCanvasToGraph();
this.nodes = [];
this.edges = [];
}
fitCanvasToGraph() {
[this.canvas.width, this.canvas.height] = this.box.rect.dimensions;
}
addVertex(...args) {
const vertex = super.addVertex(...args);
const box = this.box.addBox(vertex.id);
// Link from the graph vertex to the corresponding display box
vertex.box = box;
// Link from the display box to the corresponding graph vertex
box.vertex = vertex;
box.addClass('absolute');
box.addClass('vertex');
box.move(this.box.rect.dimensions.scale(0.5));
box.velocity = Vector.from([0, 0]);
box.setInnerHTML(vertex.getHTML());
this.nodes.push(box);
// When vertex properties are updated, re-render the node contents
vertex.onUpdate = () => {
box.setInnerHTML(vertex.getHTML());
// Maybe resolve forces
this.runUntilEquilibrium();
};
this.runUntilEquilibrium();
// Allow moving vertices with the mouse
box.el.addEventListener('mousedown', (e) => {
console.log('mousedown, button:', e.button);
if (!this.mouseMoving) {
e.preventDefault();
// Record current mouse position
this.mousePosition = Vector.from([e.clientX, e.clientY]);
// Begin tracking mouse movement
this.mouseMoving = box;
}
});
document.addEventListener('mousemove', (e) => {
if (this.mouseMoving === box) {
const mousePosition = Vector.from([e.clientX, e.clientY]);
// Apply translation
box.move(mousePosition.subtract(this.mousePosition));
// Update current mouse position
this.mousePosition = mousePosition;
// Equilibrate
this.runUntilEquilibrium();
}
});
document.addEventListener('mouseup', () => {
// Stop tracking mouse movement
this.mouseMoving = null;
// Equilibrate
this.runUntilEquilibrium();
});
return vertex;
}
addEdge(type, from, to, ...rest) {
from = from instanceof Vertex ? from : this.getVertex(from);
to = to instanceof Vertex ? to : this.getVertex(to);
if (!from) throw new Error(`from ${from}: Node not found`);
if (!to) throw new Error(`to ${to}: Node not found`);
const edge = super.addEdge(type, from, to, ...rest);
const box = this.box.addBox(Edge.getKey({ from, to, type }));
edge.box = box;
box.edge = edge;
box.addClass('absolute');
box.addClass('edge');
box.setInnerHTML(edge.getHTML());
this.edges.push(box);
// Initially place near the midpoint between the `from` and `to` nodes.
const midpoint = from.box.rect.center.add(to.box.rect.center).scale(0.5);
const startPosition = midpoint.subtract(box.rect.dimensions.scale(0.5));
box.move(startPosition);
box.velocity = Vector.from([0, 0]);
this.runUntilEquilibrium();
return edge;
}
setEdgeWeight(...args) {
const edge = super.setEdgeWeight(...args);
edge.displayEdgeNode();
return edge;
}
drawEdgeLines() {
const ctx = this.canvas.getContext('2d');
ctx.clearRect(0, 0, this.width, this.height);
ctx.strokeStyle = '#57747d';
ctx.fillStyle = '#57747d';
for (const { edge } of this.edges) {
ctx.beginPath();
ctx.moveTo(...edge.from.box.rect.center);
ctx.lineTo(...edge.box.rect.center);
ctx.stroke();
const direction = edge.to.box.rect.center.subtract(edge.box.rect.center);
const arrowTail = edge.box.rect.lineIntersect(edge.to.box.rect.center, direction.scale(-1));
const arrowPoint = edge.to.box.rect.lineIntersect(edge.box.rect.center, direction);
const arrowBaseCenter = arrowPoint.subtract(direction.normalize().scale(ARROWHEAD_LENGTH));
const arrowBaseDirection = Vector.from([direction[1], -direction[0]]).normalize();
const arrowBaseLeft = arrowBaseCenter.add(arrowBaseDirection.scale(ARROWHEAD_WIDTH));
const arrowBaseRight = arrowBaseCenter.subtract(arrowBaseDirection.scale(ARROWHEAD_WIDTH));
ctx.beginPath();
ctx.moveTo(...arrowTail);
ctx.lineTo(...arrowPoint);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(...arrowPoint);
ctx.lineTo(...arrowBaseLeft);
ctx.lineTo(...arrowBaseRight);
ctx.closePath();
ctx.fill();
}
}
async runUntilEquilibrium(tDelta = DEFAULT_TIME_STEP) {
this.steps = 0;
if (this.intervalTask) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
this.intervalTask = setInterval(() => {
if (this.intervalTaskExecuting) return;
this.steps++;
if (this.steps > MAXIMUM_STEPS) {
clearInterval(this.intervalTask);
this.intervalTask = null;
reject(new Error('Exceeded max steps to reach equilibrium'));
}
this.intervalTaskExecuting = true;
const { atEquilibrium } = this.computeEulerFrame(tDelta);
this.intervalTaskExecuting = false;
if (atEquilibrium) {
console.log(`Reached equilibrium after ${this.steps} steps`);
clearInterval(this.intervalTask);
this.intervalTask = null;
resolve();
}
}, tDelta * TIME_DILATION_FACTOR);
});
}
computeEulerFrame(tDelta = DEFAULT_TIME_STEP) {
// Compute net forces on each box in the graph
const boxes = [...this.nodes, ...this.edges];
// Initialize net force vectors
for (const box of boxes) {
box.forces = [];
}
const addForce = (box, force, type) => box.forces.push({ force, type });
// All boxes repel each other if they overlap
for (const boxA of boxes) {
const idxA = boxes.indexOf(boxA);
for (const boxB of boxes.slice(idxA + 1)) {
const force = overlapRepulsionForce(boxA, boxB);
addForce(
boxA,
force.scale(-1),
`${boxB.name} -- overlapRepulsion --> ${boxA.name}`,
);
addForce(
boxB,
force,
`${boxA.name} -- overlapRepulsion --> ${boxB.name}`,
);
}
}
// Center of graph attracts all boxes that are outside the graph
for (const box of boxes) {
if (!this.box.rect.doesContain(box.rect.center)) {
const r = this.box.rect.center.subtract(box.rect.center);
addForce(
box,
r.normalize().scale(CENTRAL_RESTORING_FORCE),
`center -- attraction --> ${box.name}`,
);
}
}
// Compute edge-related forces
for (const edgeBox of this.edges) {
const { edge } = edgeBox;
const fromBox = edge.from.box;
const toBox = edge.to.box;
// Attraction to the `from` and `to` nodes
addForce(
edgeBox,
targetRadiusForce(fromBox, edgeBox, 0),
`${edgeBox.name} -- attract fromBox --> ${fromBox.name}`,
);
addForce(
edgeBox,
targetRadiusForce(toBox, edgeBox, 0),
`${edgeBox.name} -- attract toBox --> ${toBox.name}`,
);
// Pairwise force between nodes
{
const force = targetRadiusForce(fromBox, toBox);
addForce(
fromBox,
force.scale(-1),
`${toBox.name} -- pairwise targetRadius --> ${fromBox.name}`,
);
addForce(
toBox,
force,
`${fromBox.name} -- pairwise targetRadius --> ${toBox.name}`,
);
}
}
// Do not apply forces to a box if it is being moved by the mouse
for (const box of boxes) {
if (this.mouseMoving === box) {
box.netForce = Vector.zeros(2);
} else {
box.netForce = box.forces.reduce((net, { force }) => net.add(force), Vector.from([0, 0]));
}
}
// Compute velocities
for (const box of boxes) {
box.velocity = box.velocity.add(box.netForce.scale(tDelta));
// Apply some drag
box.velocity = box.velocity.scale(1 - VISCOSITY_FACTOR);
}
// When all velocities are below MINIMUM_VELOCITY and we have executed more than
// MINIMUM_STEPS, we have reached equilibrium.
let atEquilibrium = this.steps > MINIMUM_STEPS;
// Apply velocities
for (const box of boxes) {
if (box.velocity.magnitude >= MINIMUM_VELOCITY) {
atEquilibrium = false;
box.move(box.velocity);
}
}
// Center the items by computing the bounding box and centering that
if (!this.mouseMoving) {
const topLeft = Vector.from(boxes[0].position);
const bottomRight = Vector.from(boxes[0].position);
for (const box of boxes) {
for (const vertex of box.rect.vertices) {
topLeft[0] = Math.min(topLeft[0], vertex[0]);
topLeft[1] = Math.min(topLeft[1], vertex[1]);
bottomRight[0] = Math.max(bottomRight[0], vertex[0]);
bottomRight[1] = Math.max(bottomRight[1], vertex[1]);
}
}
const boundingRect = new Rectangle(topLeft, bottomRight.subtract(topLeft));
const graphCenter = Vector.from([this.width, this.height]).scale(0.5);
const offset = graphCenter.subtract(boundingRect.center);
const translate = offset.scale(TRANSLATION_VELOCITY_FACTOR);
if (translate.magnitude >= MINIMUM_VELOCITY) {
atEquilibrium = false;
// Apply translations
for (const box of boxes) {
box.move(translate);
}
}
}
// TODO: Scaling to fit
this.drawEdgeLines();
return { atEquilibrium };
}
}

View File

@ -19,6 +19,20 @@ export class FormElement extends Box {
}
}
export class Select extends FormElement {
constructor(name, form, opts) {
super(name, form, opts);
const { options } = opts;
this.selectEl = document.createElement('select');
for (const { value, label } of options) {
const optionEl = document.createElement('option');
optionEl.setAttribute('value', value);
optionEl.innerHTML = label || value;
}
this.el.appendChild(this.selectEl);
}
}
export class Button extends FormElement {
constructor(name, form, opts) {
super(name, form, { ...opts, cbEventTypes: ['click'] });
@ -43,15 +57,18 @@ export class TextField extends FormElement {
constructor(name, form, opts) {
super(name, form, opts);
this.flex({ center: true });
this.label = document.createElement('label');
this.labelDiv = document.createElement('div');
this.label.appendChild(this.labelDiv);
this.labelDiv.innerHTML = opts.label || name;
this.input = document.createElement('input');
this.input.disabled = !!opts.disabled;
this.input.defaultValue = opts.defaultValue || '';
this.label.appendChild(this.input);
this.el.appendChild(this.label);
// Place label inside a div, for improved styling
const labelDiv = document.createElement('div');
labelDiv.innerHTML = opts.label || name;
const label = document.createElement('label');
label.appendChild(labelDiv);
const input = document.createElement('input');
input.disabled = !!opts.disabled;
input.defaultValue = opts.defaultValue || '';
label.appendChild(input);
this.el.appendChild(label);
this.input = input;
}
get value() {
@ -61,6 +78,30 @@ export class TextField extends FormElement {
export class TextArea extends FormElement { }
export class SubForm extends FormElement {
// Form has:
// this.document = document;
// this.items = [];
// this.id = opts.id ?? `form_${randomID()}`;
// FormElement has
constructor(name, form, opts) {
if (!name) {
name = `subform${randomID()}`;
}
const parentEl = opts.subFormArray ? opts.subFormArray.el : form.el;
const subForm = form.document.form({ name, parentEl, tagName: 'div' }).lastElement;
super(name, form, { ...opts, parentEl });
this.subForm = subForm;
if (opts.subFormArray) {
opts.subFormArray.subForms.push(this);
}
}
get value() {
return this.subForm.value;
}
}
export class SubFormArray extends FormElement {
constructor(name, form, opts) {
super(name, form, opts);
@ -76,36 +117,30 @@ export class SubFormArray extends FormElement {
this.subForms.splice(idx, 1);
subForm.el.remove();
}
subForm(opts = {}) {
const subForm = new SubForm(opts.name, this.form, { ...opts, subFormArray: this });
this.subForms.push(subForm);
return this;
}
export class SubForm extends FormElement {
constructor(name, form, opts) {
const parentEl = opts.subFormArray ? opts.subFormArray.el : form.el;
const subForm = form.document.form({ name, parentEl, tagName: 'div' }).lastElement;
super(name, form, { ...opts, parentEl });
this.subForm = subForm;
if (opts.subFormArray) {
opts.subFormArray.subForms.push(this);
}
}
get value() {
return this.subForm.value;
get lastSubForm() {
return this.subForms[this.subForms.length - 1];
}
}
export class FileInput extends FormElement {
constructor(name, form, opts) {
super(name, form, opts);
this.input = document.createElement('input');
this.input.type = 'file';
this.input.accept = 'application/json';
this.input.classList.add('visually-hidden');
this.label = document.createElement('label');
this.button = form.button({ name, cb: () => this.input.click() }).lastItem;
this.label.appendChild(this.button.el);
this.label.appendChild(this.input);
this.el.appendChild(this.label);
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json';
input.classList.add('visually-hidden');
const label = document.createElement('label');
const button = form.button({ name, cb: () => input.click() }).lastItem;
label.appendChild(button.el);
label.appendChild(input);
this.el.appendChild(label);
}
}
@ -119,6 +154,11 @@ export class Form extends Box {
this.el.onsubmit = () => false;
}
select(opts) {
this.items.push(new Select(opts.name, this, opts));
return this;
}
button(opts) {
this.items.push(new Button(opts.name, this, opts));
return this;
@ -154,6 +194,10 @@ export class Form extends Box {
return this;
}
remark(text, opts) {
this.document.remark(text, { ...opts, parentEl: this.el });
}
get lastItem() {
return this.items[this.items.length - 1];
}

View File

@ -0,0 +1,65 @@
import {
DEFAULT_OVERLAP_FORCE,
DEFAULT_TARGET_RADIUS,
DEFAULT_DISTANCE_FACTOR,
DEFAULT_OVERLAP_BUFFER,
OVERLAP_THRESHOLD_RANDOMIZE,
} from '../../util/constants.js';
import { Rectangle, Vector } from '../supporting/geometry/index.js';
const getRectangles = (boxes) => boxes.map((box) => (box instanceof Rectangle ? box : box.rect));
const getCenters = (boxes) => getRectangles(boxes).map((rect) => rect.center);
export const overlapRepulsionForce = (
boxA,
boxB,
force = DEFAULT_OVERLAP_FORCE,
margin = DEFAULT_OVERLAP_BUFFER,
) => {
const [rectA, rectB] = getRectangles([boxA, boxB]);
const [centerA, centerB] = getCenters([rectA, rectB]);
const r = centerB.subtract(centerA);
// Apply a stronger force when overlap occurs
const overlap = rectA.doesOverlap(rectB);
if (overlap) {
// If there is sufficient overlap, randomize the direction of force.
// Note that we don't want to keep randomizing it once we've picked a direction
if (overlap <= OVERLAP_THRESHOLD_RANDOMIZE) {
if (!boxB.overlapForceDirection) {
boxB.overlapForceDirection = Vector.randomUnitVector(rectB.dim);
}
return boxB.overlapForceDirection.scale(force);
}
return r.normalize().scale(force);
}
boxB.overlapForceDirection = null;
// Apply a weaker force until distance > margin
const separation = rectA.separationFromRect(rectB);
if (separation < margin) {
return r.normalize().scale(force * ((margin - separation) / margin));
}
// Otherwise, zero force
return Vector.zeros(rectA.dim);
};
export const targetRadiusForce = (
boxA,
boxB,
targetRadius = DEFAULT_TARGET_RADIUS,
distanceFactor = DEFAULT_DISTANCE_FACTOR,
) => {
const [rectA, rectB] = getRectangles([boxA, boxB]);
const [centerA, centerB] = getCenters([rectA, rectB]);
const r = centerB.subtract(centerA);
// Use the distance between the outer edges of the boxes.
const outerA = rectA.lineIntersect(centerB, r.scale(-1));
const outerB = rectB.lineIntersect(centerA, r);
const distance = outerB.subtract(outerA).magnitude;
// Repel if closer than targetRadius
// Attract if farther than targetRadius
const force = -distanceFactor * (distance - targetRadius);
return r.normalize().scale(force);
};

View File

@ -12,7 +12,7 @@ export class Scene {
constructor(name, rootBox) {
this.name = name;
this.box = rootBox.addBox(name);
this.titleBox = this.box.addBox('Title').setInnerHTML(name);
// this.titleBox = this.box.addBox('Title').setInnerHTML(name);
this.box.addBox('Spacer').setInnerHTML('&nbsp;');
this.topSection = this.box.addBox('Top section').flex();
this.displayValuesBox = this.topSection.addBox('Values');

View File

@ -0,0 +1,164 @@
import { Vertex } from '../supporting/vertex.js';
import { Edge } from '../supporting/edge.js';
import { Document } from './document.js';
import { WeightedDirectedGraph } from '../supporting/wdg.js';
const allGraphs = [];
const makeWDGHandler = (graphIndex) => (vertexId) => {
const graph = allGraphs[graphIndex];
// We want a document for editing this node, which may be a vertex or an edge
const { editorDoc } = graph;
editorDoc.clear();
if (vertexId.startsWith('edge:')) {
const [, from, to] = vertexId.split(':');
Edge.prepareEditorDocument(graph, editorDoc, from, to);
} else {
Vertex.prepareEditorDocument(graph, editorDoc, vertexId);
}
};
export class WDGDiagram extends WeightedDirectedGraph {
constructor(name, scene, options = {}) {
super(name, options);
this.scene = scene;
this.flowchart = scene?.flowchart;
this.editable = options.editable;
// Mermaid supports a click callback, but we can't customize arguments; we just get the vertex ID.
// In order to provide the appropriate graph context for each callback, we create a separate callback
// function for each graph.
this.index = allGraphs.length;
allGraphs.push(this);
window[`WDGHandler${this.index}`] = makeWDGHandler(this.index);
}
fromJSON(...args) {
super.fromJSON(...args);
this.redraw();
return this;
}
addVertex(...args) {
const vertex = super.addVertex(...args);
vertex.displayVertex();
return vertex;
}
addEdge(type, from, to, ...rest) {
const existingEdges = this.getEdges(null, from, to);
const edge = super.addEdge(type, from, to, ...rest);
if (existingEdges.length) {
edge.displayEdgeNode();
} else {
edge.displayEdge();
}
return edge;
}
setEdgeWeight(...args) {
const edge = super.setEdgeWeight(...args);
edge.displayEdgeNode();
return edge;
}
redraw() {
// Call .reset() on all vertices and edges
for (const vertex of this.vertices.values()) {
vertex.reset();
}
for (const edges of this.edgeTypes.values()) {
for (const edge of edges.values()) {
edge.reset();
}
}
// Clear the target div
this.flowchart?.reset();
this.flowchart?.init();
// Draw all vertices and edges
for (const vertex of this.vertices.values()) {
vertex.displayVertex();
}
// Let's flatmap and dedupe by [from, to] since each edge
// renders all comorphic edges as well.
const edgesFrom = new Map(); // edgeMap[from][to] = edge
for (const edges of this.edgeTypes.values()) {
for (const edge of edges.values()) {
const edgesTo = edgesFrom.get(edge.from) || new Map();
edgesTo.set(edge.to, edge);
edgesFrom.set(edge.from, edgesTo);
}
}
for (const edgesTo of edgesFrom.values()) {
for (const edge of edgesTo.values()) {
edge.displayEdge();
}
}
// Ensure rerender
this.flowchart?.render();
}
withFlowchart() {
this.scene?.withSectionFlowchart();
this.flowchart = this.scene?.lastFlowchart;
if (this.editable) {
this.controlDoc = new Document('WDGControl', this.flowchart.box.el, { prepend: true });
this.editorDoc = new Document('WDGEditor', this.flowchart.box.el);
this.errorDoc = new Document('WDGErrors', this.flowchart.box.el);
this.resetEditor();
}
return this;
}
prepareControlDoc() {
const form = this.controlDoc.form({ name: 'controlForm' }).lastElement;
const { subForm: graphPropertiesForm } = form.subForm({ name: 'graphPropsForm' }).lastItem;
graphPropertiesForm.flex()
.textField({
name: 'name',
label: 'Graph name',
defaultValue: this.name,
cb: (({ form: { value: { name } } }) => {
this.name = name;
}),
});
const { subForm: exportImportForm } = form.subForm({ name: 'exportImportForm' }).lastItem;
exportImportForm.flex()
.button({
name: 'Export',
cb: () => {
const a = window.document.createElement('a');
const json = JSON.stringify(this.toJSON(), null, 2);
const currentTime = Math.floor(new Date().getTime() / 1000);
a.href = `data:attachment/text,${encodeURI(json)}`;
a.target = '_blank';
a.download = `wdg_${this.name}_${currentTime}.json`;
a.click();
},
})
.fileInput({
name: 'Import',
cb: ({ input: { files: [file] } }) => {
const reader = new FileReader();
reader.onload = ({ target: { result: text } }) => {
console.log('imported file', { file });
// this.flowchart?.log(`%% Imported file ${file}`)
const data = JSON.parse(text);
this.fromJSON(data);
};
reader.readAsText(file);
},
});
}
resetEditor() {
this.editorDoc.clear();
this.controlDoc.clear();
this.prepareControlDoc();
Vertex.prepareEditorDocument(this, this.editorDoc);
}
}

View File

@ -1,56 +1,64 @@
import { ERC721 } from '../supporting/erc721.js';
import { ERC1155 } from '../supporting/erc1155.js';
import { randomID } from '../../util/helpers.js';
import { EPSILON } from '../../util/constants.js';
class Lock {
constructor(tokenId, amount, duration) {
constructor(tokenAddress, tokenTypeId, amount, duration) {
this.dateCreated = new Date();
this.tokenId = tokenId;
this.tokenAddress = tokenAddress;
this.amount = amount;
this.duration = duration;
this.tokenTypeId = tokenTypeId;
}
}
export class ReputationTokenContract extends ERC721 {
export class ReputationTokenContract extends ERC1155 {
constructor() {
super('Reputation', 'REP');
this.histories = new Map(); // token id --> {increment, context (i.e. validation pool id)}
this.values = new Map(); // token id --> current value
this.locks = new Set(); // {tokenId, amount, start, duration}
this.histories = new Map(); // token address --> {tokenTypeId, increment, context (i.e. validation pool id)}
this.values = new Map(); // token address --> token type id --> current value
this.locks = new Set(); // {tokenAddress, tokenTypeId, amount, start, duration}
}
/**
*
* @param to
* @param value
* @param context
* @param to Recipient address
* @param values Object with reputation type id as key, and amount of reputation as value
* @returns {string}
*/
mint(to, value, context = {}) {
const tokenId = `token_${randomID()}`;
super.mint(to, tokenId);
this.values.set(tokenId, value);
this.histories.set(tokenId, [{ increment: value, context }]);
return tokenId;
mintBatch(to, tokenTypeIds, values) {
const tokenAddress = `token_${randomID()}`;
super.mintBatch(to, tokenAddress, tokenTypeIds, tokenTypeIds.map(() => 1));
const tokenMap = new Map();
for (let idx = 0; idx < tokenTypeIds.length; idx++) {
const tokenTypeId = tokenTypeIds[idx];
const value = values[idx];
tokenMap.set(tokenTypeId, value);
}
this.values.set(tokenAddress, tokenMap);
this.histories.set(tokenAddress, [{ operation: 'mintBatch', args: { to, tokenTypeIds, values } }]);
return tokenAddress;
}
incrementValue(tokenId, increment, context) {
const value = this.values.get(tokenId);
if (value === undefined) {
throw new Error(`Token not found: ${tokenId}`);
incrementValue(tokenAddress, tokenTypeId, increment, context) {
const tokenTypeIds = this.values.get(tokenAddress);
if (tokenTypeIds === undefined) {
throw new Error(`Token not found: ${tokenAddress}`);
}
const value = tokenTypeIds?.get(tokenTypeId);
const newValue = value + increment;
const history = this.histories.get(tokenId) || [];
const history = this.histories.get(tokenAddress) || [];
if (newValue < -EPSILON) {
throw new Error(`Token value can not become negative. Attempted to set value = ${newValue}`);
}
this.values.set(tokenId, newValue);
history.push({ increment, context });
this.histories.set(tokenId, history);
tokenTypeIds.set(tokenAddress, newValue);
this.values.set(tokenAddress, tokenTypeIds);
history.push({ tokenTypeId, increment, context });
this.histories.set(tokenAddress, history);
}
transferValueFrom(fromTokenId, toTokenId, amount) {
transferValueFrom(from, to, tokenTypeId, amount) {
if (amount === undefined) {
throw new Error('Transfer value: amount is undefined!');
}
@ -60,64 +68,83 @@ export class ReputationTokenContract extends ERC721 {
if (amount < 0) {
throw new Error('Transfer value: amount must be positive');
}
const sourceAvailable = this.availableValueOf(fromTokenId);
const sourceAvailable = this.availableValueOf(from, tokenTypeId);
if (sourceAvailable < amount - EPSILON) {
throw new Error('Token value transfer: source has insufficient available value. '
+ `Needs ${amount}; has ${sourceAvailable}.`);
}
this.incrementValue(fromTokenId, -amount);
this.incrementValue(toTokenId, amount);
this.incrementValue(from, tokenTypeId, -amount);
this.incrementValue(to, tokenTypeId, amount);
}
lock(tokenId, amount, duration) {
const lock = new Lock(tokenId, amount, duration);
batchTransferValueFrom(from, to, tokenTypeIds, amounts) {
for (let idx = 0; idx < tokenTypeIds.length; idx++) {
const tokenTypeId = tokenTypeIds[idx];
const amount = amounts[idx];
if (amount === undefined) {
throw new Error('Transfer value: amount is undefined!');
}
if (amount === 0) {
return;
}
if (amount < 0) {
throw new Error('Transfer value: amount must be positive');
}
const sourceAvailable = this.availableValueOf(from, tokenTypeId);
if (sourceAvailable < amount - EPSILON) {
throw new Error('Token value transfer: source has insufficient available value. '
+ `Needs ${amount}; has ${sourceAvailable}.`);
}
this.incrementValue(from, tokenTypeId, -amount);
this.incrementValue(to, tokenTypeId, amount);
}
}
lock(tokenAddress, tokenTypeId, amount, duration) {
const lock = new Lock(tokenAddress, tokenTypeId, amount, duration);
this.locks.add(lock);
}
historyOf(tokenId) {
return this.histories.get(tokenId);
historyOf(tokenAddress) {
return this.histories.get(tokenAddress);
}
valueOf(tokenId) {
const value = this.values.get(tokenId);
if (value === undefined) {
throw new Error(`Token not found: ${tokenId}`);
valueOf(tokenAddress, tokenTypeId) {
const tokenTypeIds = this.values.get(tokenAddress);
if (tokenTypeIds === undefined) {
throw new Error(`Token not found: ${tokenAddress}`);
}
return value;
return tokenTypeIds.get(tokenTypeId);
}
availableValueOf(tokenId) {
availableValueOf(tokenAddress, tokenTypeId) {
const amountLocked = Array.from(this.locks.values())
.filter(({ tokenId: lockTokenId }) => lockTokenId === tokenId)
.filter((lock) => lock.tokenAddress === tokenAddress && lock.tokenTypeId === tokenTypeId)
.filter(({ dateCreated, duration }) => new Date() - dateCreated < duration)
.reduce((total, { amount }) => total += amount, 0);
return this.valueOf(tokenId) - amountLocked;
return this.valueOf(tokenAddress, tokenTypeId) - amountLocked;
}
valueOwnedBy(ownerId) {
valueOwnedBy(ownerAddress, tokenTypeId) {
return Array.from(this.owners.entries())
.filter(([__, owner]) => owner === ownerId)
.map(([tokenId, __]) => this.valueOf(tokenId))
.filter(([__, owner]) => owner === ownerAddress)
.map(([tokenAddress, __]) => this.valueOf(tokenAddress, tokenTypeId))
.reduce((total, value) => total += value, 0);
}
availableValueOwnedBy(ownerId) {
availableValueOwnedBy(ownerAddress, tokenTypeId) {
return Array.from(this.owners.entries())
.filter(([__, owner]) => owner === ownerId)
.map(([tokenId, __]) => this.availableValueOf(tokenId))
.filter(([__, owner]) => owner === ownerAddress)
.map(([tokenAddress, __]) => this.availableValueOf(tokenAddress, tokenTypeId))
.reduce((total, value) => total += value, 0);
}
getTotal() {
return Array.from(this.values.values()).reduce((total, value) => total += value, 0);
getTotal(tokenTypeId) {
return Array.from(this.values.values())
.flatMap((tokens) => tokens.get(tokenTypeId))
.reduce((total, value) => total += value, 0);
}
getTotalAvailable() {
const amountLocked = Array.from(this.locks.values())
.filter(({ dateCreated, duration }) => new Date() - dateCreated < duration)
.reduce((total, { amount }) => total += amount, 0);
return this.getTotal() - amountLocked;
}
// burn(tokenAddress, tokenTypeId, )
}

View File

@ -37,7 +37,7 @@ export class Edge {
return this.graph.getEdges(null, this.from, this.to);
}
getHtml() {
getHTML() {
const edges = this.getComorphicEdges();
let html = '';
html += '<table>';
@ -49,22 +49,18 @@ export class Edge {
return html;
}
getFlowchartNode() {
return `${Edge.getCombinedKey(this)}("${this.getHtml()}")`;
}
displayEdgeNode() {
if (this.options.hide) {
return;
}
this.graph.flowchart?.log(this.getFlowchartNode());
this.graph.flowchart?.log(this.getHTML());
}
displayEdge() {
if (this.options.hide) {
return;
}
this.graph.flowchart?.log(`${this.from.id} --- ${this.getFlowchartNode()} --> ${this.to.id}`);
this.graph.flowchart?.log(`${this.from.id} --- ${this.getHTML()} --> ${this.to.id}`);
this.graph.flowchart?.log(`class ${Edge.getCombinedKey(this)} edge`);
if (this.graph.editable && !this.installedClickCallback) {
this.graph.flowchart?.log(`click ${Edge.getCombinedKey(this)} WDGHandler${this.graph.index} \
@ -75,6 +71,12 @@ export class Edge {
static prepareEditorDocument(graph, doc, from, to) {
const form = doc.form({ name: 'editorForm' }).lastElement;
form.button({
name: 'New Vertex',
cb: () => {
graph.resetEditor();
},
});
doc.remark('<h3>Edit Edge</h3>', { parentEl: form.el });
form
.textField({
@ -88,7 +90,7 @@ export class Edge {
const subFormArray = form.subFormArray({ id: 'edges', name: 'edges' }).lastItem;
const addEdgeForm = (edge) => {
const { subForm } = form.subForm({ name: 'subform', subFormArray }).lastItem;
const { subForm } = form.subForm({ subFormArray }).lastItem;
subForm.textField({
id: 'type', name: 'type', defaultValue: edge.type, required: true,
})

View File

@ -0,0 +1,105 @@
/**
* ERC-1155: Multi Token Standard
* See https://eips.ethereum.org/EIPS/eip-1155
* and https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC1155/ERC1155.sol
*
* This implementation is currently incomplete. It lacks the following:
* - Token approvals
* - Operator approvals
* - Emitting events
* - transferFrom
*/
export class ERC1155 {
constructor(name, symbol) {
this.name = name;
this.symbol = symbol;
this.balances = new Map(); // owner address --> token id --> token count
this.owners = new Map(); // token address --> owner address
// this.tokenApprovals = new Map(); // token address --> approved addresses
// this.operatorApprovals = new Map(); // ownerAddress --> operator approvals
this.events = {
// Transfer: (_from, _to, _tokenAddress) => {},
// Approval: (_owner, _approved, _tokenAddress) => {},
// ApprovalForAll: (_owner, _operator, _approved) => {},
};
}
incrementBalance(ownerAddress, tokenTypeId, increment) {
const tokens = this.balances.get(ownerAddress) ?? new Map();
const balance = tokens.get(tokenTypeId) ?? 0;
tokens.set(tokenTypeId, balance + increment);
this.balances.set(ownerAddress, tokens);
}
mintBatch(to, tokenAddress, tokenTypeIds, amounts = null) {
if (!amounts) {
amounts = tokenTypeIds.map(() => 1);
}
console.log('ERC1155.mintBatch', {
to, tokenAddress, tokenTypeIds, amounts,
});
if (this.owners.get(tokenAddress)) {
throw new Error('ERC1155: token already minted');
}
for (let idx = 0; idx < tokenTypeIds.length; idx++) {
const tokenTypeId = tokenTypeIds[idx];
const amount = amounts[idx];
this.incrementBalance(to, tokenTypeId, amount);
}
this.owners.set(tokenAddress, to);
}
burn(tokenAddress, tokenTypeId, amount = 1) {
const ownerAddress = this.owners.get(tokenAddress);
this.incrementBalance(ownerAddress, tokenTypeId, -amount);
}
balanceOf(ownerAddress, tokenTypeId) {
if (!ownerAddress) {
throw new Error('ERC1155: address zero is not a valid owner');
}
const tokens = this.balances.get(ownerAddress) ?? new Map();
return tokens.get(tokenTypeId) ?? 0;
}
ownerOf(tokenAddress) {
const ownerAddress = this.owners.get(tokenAddress);
if (!ownerAddress) {
throw new Error(`ERC1155: invalid token address: ${tokenAddress}`);
}
return ownerAddress;
}
transfer(from, to, tokenAddress) {
console.log('ERC1155.transfer', { from, to, tokenAddress });
const ownerAddress = this.owners.get(tokenAddress);
if (ownerAddress !== from) {
throw new Error(`ERC1155: transfer from incorrect owner ${from}; should be ${ownerAddress}`);
}
this.incrementBalance(from, -1);
this.incrementBalance(to, 1);
this.owners.set(tokenAddress, to);
}
/// @notice Enable or disable approval for a third party ("operator") to manage
/// all of `msg.sender`'s assets
/// @dev Emits the ApprovalForAll event. The contract MUST allow
/// multiple operators per ownerAddress.
/// @param _operator Address to add to the set of authorized operators
/// @param _approved True if the operator is approved, false to revoke approval
// setApprovalForAll(_operator, _approved) {}
/// @notice Get the approved address for a single NFT
/// @dev Throws if `_tokenAddress` is not a valid NFT.
/// @param _tokenAddress The NFT to find the approved address for
/// @return The approved address for this NFT, or the zero address if there is none
// getApproved(_tokenAddress) {}
/// @notice Query if an address is an authorized operator for another address
/// @param _owner The address that owns the NFTs
/// @param _operator The address that acts on behalf of the ownerAddress
/// @return True if `_operator` is an approved operator for `_owner`, false otherwise
// isApprovedForAll(_owner, _operator) {}
}

View File

@ -16,78 +16,78 @@ export class ERC721 {
this.balances = new Map(); // owner address --> token count
this.owners = new Map(); // token id --> owner address
// this.tokenApprovals = new Map(); // token id --> approved addresses
// this.operatorApprovals = new Map(); // owner --> operator approvals
// this.operatorApprovals = new Map(); // ownerAddress --> operator approvals
this.events = {
// Transfer: (_from, _to, _tokenId) => {},
// Approval: (_owner, _approved, _tokenId) => {},
// Transfer: (_from, _to, _tokenAddress) => {},
// Approval: (_owner, _approved, _tokenAddress) => {},
// ApprovalForAll: (_owner, _operator, _approved) => {},
};
}
incrementBalance(owner, increment) {
const balance = this.balances.get(owner) ?? 0;
this.balances.set(owner, balance + increment);
incrementBalance(ownerAddress, increment) {
const balance = this.balances.get(ownerAddress) ?? 0;
this.balances.set(ownerAddress, balance + increment);
}
mint(to, tokenId) {
console.log('ERC721.mint', { to, tokenId });
if (this.owners.get(tokenId)) {
mint(to, tokenAddress) {
console.log('ERC721.mint', { to, tokenAddress });
if (this.owners.get(tokenAddress)) {
throw new Error('ERC721: token already minted');
}
this.incrementBalance(to, 1);
this.owners.set(tokenId, to);
this.owners.set(tokenAddress, to);
}
burn(tokenId) {
const owner = this.owners.get(tokenId);
this.incrementBalance(owner, -1);
this.owners.delete(tokenId);
burn(tokenAddress) {
const ownerAddress = this.owners.get(tokenAddress);
this.incrementBalance(ownerAddress, -1);
this.owners.delete(tokenAddress);
}
balanceOf(owner) {
if (!owner) {
balanceOf(ownerAddress) {
if (!ownerAddress) {
throw new Error('ERC721: address zero is not a valid owner');
}
return this.balances.get(owner) ?? 0;
return this.balances.get(ownerAddress) ?? 0;
}
ownerOf(tokenId) {
const owner = this.owners.get(tokenId);
if (!owner) {
throw new Error(`ERC721: invalid token ID: ${tokenId}`);
ownerOf(tokenAddress) {
const ownerAddress = this.owners.get(tokenAddress);
if (!ownerAddress) {
throw new Error(`ERC721: invalid token ID: ${tokenAddress}`);
}
return owner;
return ownerAddress;
}
transfer(from, to, tokenId) {
console.log('ERC721.transfer', { from, to, tokenId });
const owner = this.owners.get(tokenId);
if (owner !== from) {
throw new Error(`ERC721: transfer from incorrect owner ${from}; should be ${owner}`);
transfer(from, to, tokenAddress) {
console.log('ERC721.transfer', { from, to, tokenAddress });
const ownerAddress = this.owners.get(tokenAddress);
if (ownerAddress !== from) {
throw new Error(`ERC721: transfer from incorrect owner ${from}; should be ${ownerAddress}`);
}
this.incrementBalance(from, -1);
this.incrementBalance(to, 1);
this.owners.set(tokenId, to);
this.owners.set(tokenAddress, to);
}
/// @notice Enable or disable approval for a third party ("operator") to manage
/// all of `msg.sender`'s assets
/// @dev Emits the ApprovalForAll event. The contract MUST allow
/// multiple operators per owner.
/// multiple operators per ownerAddress.
/// @param _operator Address to add to the set of authorized operators
/// @param _approved True if the operator is approved, false to revoke approval
// setApprovalForAll(_operator, _approved) {}
/// @notice Get the approved address for a single NFT
/// @dev Throws if `_tokenId` is not a valid NFT.
/// @param _tokenId The NFT to find the approved address for
/// @dev Throws if `_tokenAddress` is not a valid NFT.
/// @param _tokenAddress The NFT to find the approved address for
/// @return The approved address for this NFT, or the zero address if there is none
// getApproved(_tokenId) {}
// getApproved(_tokenAddress) {}
/// @notice Query if an address is an authorized operator for another address
/// @param _owner The address that owns the NFTs
/// @param _operator The address that acts on behalf of the owner
/// @param _operator The address that acts on behalf of the ownerAddress
/// @return True if `_operator` is an approved operator for `_owner`, false otherwise
// isApprovedForAll(_owner, _operator) {}
}

View File

@ -0,0 +1,3 @@
export * from './vector.js';
export * from './polygon.js';
export * from './rectangle.js';

View File

@ -0,0 +1,18 @@
import { Vector } from './vector.js';
export class Polygon {
constructor() {
this.vertices = [];
this.dim = 0;
}
addVertex(point) {
point = point instanceof Vector ? point : Vector.from(point);
if (!this.dim) {
this.dim = point.dim;
} else if (this.dim !== point.dim) {
throw new Error('All vertices of a polygon must have the same dimensionality');
}
this.vertices.push(point);
}
}

View File

@ -0,0 +1,104 @@
import { DEFAULT_OVERLAP_BUFFER } from '../../../util/constants.js';
import { Polygon } from './polygon.js';
import { Vector } from './vector.js';
export class Rectangle extends Polygon {
constructor(position, dimensions) {
super();
if (this.vertices.length) {
throw new Error('Reinitializing geometry is not allowed');
}
this.position = Vector.from(position);
this.dimensions = Vector.from(dimensions);
// Next point is obtained by moving the specified length along each dimension
// one at a time, then reversing these movements in the same order.
let point = Vector.from(this.position);
for (let dim = 0; dim < point.dim; dim++) {
this.addVertex(point);
const increment = Vector.unitVector(dim, point.dim).scale(this.dimensions[dim]);
point = point.add(increment);
}
for (let dim = 0; dim < point.dim; dim++) {
this.addVertex(point);
const increment = Vector.unitVector(dim, point.dim).scale(this.dimensions[dim]);
point = point.subtract(increment);
}
}
get center() {
return Vector.from(this.dimensions.map((Q, idx) => this.position[idx] + Q / 2));
}
doesOverlap(rect) {
const overlapFractions = this.dimensions.map((_, dim) => {
const thisMin = this.position[dim];
const thisMax = this.position[dim] + this.dimensions[dim];
const thatMin = rect.position[dim];
const thatMax = rect.position[dim] + rect.dimensions[dim];
if (thatMin <= thisMin && thatMax >= thisMin) {
if (thatMax >= thisMax) {
return 1;
}
return (thatMax - thisMin) / (thatMax - thatMin);
}
if (thatMin <= thisMax && thatMax >= thisMin) {
if (thatMax <= thisMax) {
return 1;
}
return (thisMax - thatMin) / (thatMax - thatMin);
}
return 0;
});
if (overlapFractions.every((x) => x > 0)) {
return Math.max(...overlapFractions);
}
return 0;
}
doesContain(point) {
return this.dimensions.every((_, dim) => {
const thisMin = this.position[dim];
const thisMax = this.position[dim] + this.dimensions[dim];
return point[dim] >= thisMin && point[dim] <= thisMax;
});
}
get aspectRatio() {
const [width, height] = this.dimensions;
return height / width;
}
lineIntersect(startPoint, direction) {
const r = Vector.from(direction).normalize();
let point = Vector.from(startPoint);
const maxDistance = this.center.subtract(point).magnitude;
let increment = maxDistance;
let everInside = false;
for (let i = 0; i < 15; i++) {
if (this.doesContain(point)) {
everInside = true;
point = point.subtract(r.scale(increment));
} else {
point = point.add(r.scale(increment));
}
increment /= 2;
}
return everInside ? point : null;
}
addMargin(margin = DEFAULT_OVERLAP_BUFFER) {
const position = this.position.subtract([margin, margin]);
const dimensions = this.dimensions.add([2 * margin, 2 * margin]);
return new Rectangle(position, dimensions);
}
separationFromRect(rect) {
if (this.doesOverlap(rect)) {
return 0;
}
const r = rect.center.subtract(this.center);
const outerA = this.lineIntersect(rect.center, r.scale(-1));
const outerB = rect.lineIntersect(this.center, r);
return outerA.subtract(outerB).magnitude;
}
}

View File

@ -0,0 +1,49 @@
export class Vector extends Array {
get dim() {
return this.length ?? 0;
}
add(vector) {
vector = vector instanceof Vector ? vector : Vector.from(vector);
if (vector.dim !== this.dim) {
throw new Error('Can only add vectors of the same dimensions');
}
return Vector.from(this.map((q, idx) => q + vector[idx]));
}
subtract(vector) {
vector = vector instanceof Vector ? vector : Vector.from(vector);
if (vector.dim !== this.dim) {
throw new Error('Can only subtract vectors of the same dimensions');
}
return Vector.from(this.map((q, idx) => q - vector[idx]));
}
static unitVector(dim, totalDim) {
return Vector.from(Array(totalDim), (_, idx) => (idx === dim ? 1 : 0));
}
get magnitudeSquared() {
return this.reduce((total, q) => total += q ** 2, 0);
}
get magnitude() {
return Math.sqrt(this.magnitudeSquared);
}
scale(factor) {
return Vector.from(this.map((q) => q * factor));
}
normalize() {
return this.scale(1 / this.magnitude);
}
static randomUnitVector(totalDim) {
return Vector.from(Array(totalDim), () => Math.random() - 0.5).normalize();
}
static zeros(totalDim) {
return Vector.from(Array(totalDim), () => 0);
}
}

View File

@ -0,0 +1,123 @@
import { Document } from '../display/document.js';
export const PropertyTypes = {
string: 'string',
number: 'number',
};
class Property {
constructor(name, type) {
this.name = name;
this.type = type;
}
}
class NodeType {
constructor(name) {
this.name = name;
this.properties = new Set();
}
addProperty(name, type) {
this.properties.add(new Property(name, type));
}
}
class EdgeType {
constructor(name, fromNodeTypes, toNodeTypes) {
this.name = name;
this.fromNodeTypes = fromNodeTypes;
this.toNodeTypes = toNodeTypes;
}
}
export class Schema {
constructor() {
this.nodeTypes = new Set();
this.edgeTypes = new Set();
}
addNodeType(name) {
const nodeType = new NodeType(name);
this.nodeTypes.add(nodeType);
}
addEdgeType(name) {
const edgeType = new EdgeType(name);
this.nodeTypes.add(edgeType);
}
}
export class SchemaEditor {
constructor(name, scene, options = {}) {
this.name = name;
this.scene = scene;
this.options = options;
this.schemaEditorBox = scene.middleSection.addBox('Schema Editor').flex();
this.nodeEditorDoc = new Document('NodeSchemaEditor', this.schemaEditorBox.el);
this.edgeEditorDoc = new Document('EdgeSchemaEditor', this.schemaEditorBox.el);
this.prepareNodeEditorDocument();
this.prepareEdgeEditorDocument();
}
prepareNodeEditorDocument(schema = new Schema()) {
const doc = this.nodeEditorDoc;
doc.clear();
doc.remark('<h2>Node Types</h2>');
const form = doc.form({ name: 'Node Types Editor' }).lastElement;
const nodeTypesSubFormArray = form.subFormArray({ name: 'nodeTypes' }).lastItem;
const addNodeForm = (name, properties) => {
const nodeTypeForm = nodeTypesSubFormArray.subForm().lastSubForm;
if (name) {
nodeTypeForm.remark(`<h3>Node Type: ${name}</h3>`);
} else {
nodeTypeForm.remark('<h3>New Node Type</h3>');
}
nodeTypeForm.textField({ name: 'name', defaultValue: name });
const propertiesSubFormArray = nodeTypeForm.subFormArray({ name: 'properties' }).lastItem;
for (const property of properties.values()) {
const propertyForm = propertiesSubFormArray.subForm().lastSubForm;
propertyForm.textField({ name: 'name', defaultValue: property.name });
propertyForm.textField({ name: 'type', defaultValue: property.type });
}
};
for (const { name, properties } of schema.nodeTypes.values()) {
addNodeForm(name, properties);
}
form.button({
name: 'Add Node Type',
cb: () => {
addNodeForm('', new Set());
},
});
form.submit({
name: 'Save',
cb: ({ form: { value: formValue } }) => {
console.log('save', { formValue });
},
});
}
prepareEdgeEditorDocument(schema = new Schema()) {
const doc = this.edgeEditorDoc;
doc.clear();
doc.remark('<h2>Edge Types</h2>');
const form = doc.form('Edge Types Editor').lastElement;
for (const { name } of schema.edgeTypes.values()) {
form.remark(`<h3>Edge Type: ${name}</h3>`);
form.textField({ name: 'name', defaultValue: name });
}
form.submit({
name: 'Save',
});
}
}
// Properties
// Data types
// Relationships

View File

@ -1,14 +1,15 @@
export class Stake {
constructor({
tokenId, position, amount, lockingTime,
tokenAddress, tokenTypeId, position, amount, lockingTime,
}) {
this.tokenId = tokenId;
this.tokenAddress = tokenAddress;
this.position = position;
this.amount = amount;
this.lockingTime = lockingTime;
this.tokenTypeId = tokenTypeId;
}
getStakeValue({ lockingTimeExponent } = {}) {
getAmount({ lockingTimeExponent } = {}) {
return this.amount * this.lockingTime ** lockingTimeExponent;
}
}

View File

@ -15,7 +15,7 @@ export class Vertex {
to: [],
};
this.installedClickCallback = false;
this.properties = new Map();
this.properties = options.properties ?? new Map();
}
toJSON() {
@ -40,29 +40,37 @@ export class Vertex {
);
}
setProperty(key, value) {
async setProperty(key, value) {
this.properties.set(key, value);
await this.onUpdate?.();
return this;
}
getHTML() {
let html = '';
if (this.type) {
html += `<span class='small'>${this.type}</span><br>`;
}
html += `${this.label || this.id}`;
html += '<table>';
for (const [key, value] of this.properties.entries()) {
const displayValue = typeof value === 'number' ? displayNumber(value) : value;
html += `<tr><td>${key}</td><td>${displayValue}</td></tr>`;
}
html += '</table>';
if (this.label && this.id !== this.label) {
html += `<span class=small>${this.id}</span><br>`;
}
html = html.replaceAll(/\n\s*/g, '');
return html;
}
displayVertex() {
if (this.options.hide) {
return;
}
let html = '';
html += `${this.label}`;
html += '<table>';
for (const [key, value] of this.properties.entries()) {
const displayValue = typeof value === 'number' ? displayNumber(value) : value;
html += `<tr><td>${key}</td><td>${displayValue}</td></tr>`;
}
html += '</table>';
if (this.id !== this.label) {
html += `<span class=small>${this.id}</span><br>`;
}
html = html.replaceAll(/\n\s*/g, '');
this.graph.flowchart?.log(`${this.id}["${html}"]`);
this.graph.flowchart?.log(`${this.id}["${this.getHTML()}"]`);
if (this.graph.editable && !this.installedClickCallback) {
this.graph.flowchart?.log(`click ${this.id} WDGHandler${this.graph.index} "Edit Vertex ${this.id}"`);
@ -73,26 +81,34 @@ export class Vertex {
static prepareEditorDocument(graph, doc, vertexId) {
const vertex = vertexId ? graph.getVertex(vertexId) : undefined;
const form = doc.form().lastElement;
if (vertex) {
form.button({
name: 'New Vertex',
cb: () => {
graph.resetEditor();
},
});
}
doc.remark(`<h3>${vertex ? 'Edit' : 'Add'} Vertex</h3>`, { parentEl: form.el });
form
.textField({
id: 'id', name: 'id', defaultValue: vertex?.id,
name: 'id', defaultValue: vertex?.id,
})
.textField({ id: 'type', name: 'type', defaultValue: vertex?.type })
.textField({ id: 'label', name: 'label', defaultValue: vertex?.label });
.textField({ name: 'type', defaultValue: vertex?.type })
.textField({ name: 'label', defaultValue: vertex?.label });
doc.remark('<h4>Properties</h4>', { parentEl: form.el });
const subFormArray = form.subFormArray({ id: 'properties', name: 'properties' }).lastItem;
const subFormArray = form.subFormArray({ name: 'properties' }).lastItem;
const addPropertyForm = (key, value) => {
const { subForm } = form.subForm({ name: 'subform', subFormArray }).lastItem;
subForm.textField({ id: 'key', name: 'key', defaultValue: key })
.textField({ id: 'value', name: 'value', defaultValue: value })
const { subForm } = form.subForm({ subFormArray }).lastItem;
subForm.textField({ name: 'key', defaultValue: key })
.textField({ name: 'value', defaultValue: value })
.button({
id: 'remove',
name: 'Remove Property',
cb: () => subFormArray.remove(subForm),
});
doc.remark('<br>', { parentEl: subForm.el });
})
.remark('<br>');
};
if (vertex) {
@ -102,13 +118,11 @@ export class Vertex {
}
form.button({
id: 'add',
name: 'Add Property',
cb: () => addPropertyForm('', ''),
});
form.submit({
id: 'save',
name: 'Save',
cb: ({ form: { value: formValue } }) => {
let fullRedraw = false;
@ -121,7 +135,10 @@ export class Vertex {
Object.assign(vertex, formValue);
vertex.displayVertex();
} else {
const newVertex = graph.addVertex(formValue.type, formValue.id, null, formValue.label);
const {
type, id, label, properties,
} = formValue;
const newVertex = graph.addVertex(type, id, null, label, { properties });
Object.assign(newVertex, formValue);
doc.clear();
Vertex.prepareEditorDocument(graph, doc, newVertex.id);
@ -134,7 +151,6 @@ export class Vertex {
if (vertex) {
form.button({
id: 'delete',
name: 'Delete Vertex',
cb: () => {
graph.deleteVertex(vertex.id);
@ -159,7 +175,6 @@ export class Vertex {
}
form.button({
id: 'cancel',
name: 'Cancel',
cb: () => graph.resetEditor(),
parentEl: doc.el,

View File

@ -1,38 +1,12 @@
import { Vertex } from './vertex.js';
import { Edge } from './edge.js';
import { Document } from '../display/document.js';
const allGraphs = [];
const makeWDGHandler = (graphIndex) => (vertexId) => {
const graph = allGraphs[graphIndex];
// We want a document for editing this node, which may be a vertex or an edge
const { editorDoc } = graph;
editorDoc.clear();
if (vertexId.startsWith('edge:')) {
const [, from, to] = vertexId.split(':');
Edge.prepareEditorDocument(graph, editorDoc, from, to);
} else {
Vertex.prepareEditorDocument(graph, editorDoc, vertexId);
}
};
export class WeightedDirectedGraph {
constructor(name, scene, options = {}) {
constructor(name) {
this.name = name;
this.scene = scene;
this.vertices = new Map();
this.edgeTypes = new Map();
this.nextVertexId = 0;
this.flowchart = scene?.flowchart;
this.editable = options.editable;
// Mermaid supports a click callback, but we can't customize arguments; we just get the vertex ID.
// In order to provide the appropriate graph context for each callback, we create a separate callback
// function for each graph.
this.index = allGraphs.length;
allGraphs.push(this);
window[`WDGHandler${this.index}`] = makeWDGHandler(this.index);
// TODO: Populate history
this.history = [];
@ -72,108 +46,9 @@ export class WeightedDirectedGraph {
} of edges) {
this.addEdge(type, from, to, weight);
}
this.redraw();
}
redraw() {
// Call .reset() on all vertices and edges
for (const vertex of this.vertices.values()) {
vertex.reset();
}
for (const edges of this.edgeTypes.values()) {
for (const edge of edges.values()) {
edge.reset();
}
}
// Clear the target div
this.flowchart?.reset();
this.flowchart?.init();
// Draw all vertices and edges
for (const vertex of this.vertices.values()) {
vertex.displayVertex();
}
// Let's flatmap and dedupe by [from, to] since each edge
// renders all comorphic edges as well.
const edgesFrom = new Map(); // edgeMap[from][to] = edge
for (const edges of this.edgeTypes.values()) {
for (const edge of edges.values()) {
const edgesTo = edgesFrom.get(edge.from) || new Map();
edgesTo.set(edge.to, edge);
edgesFrom.set(edge.from, edgesTo);
}
}
for (const edgesTo of edgesFrom.values()) {
for (const edge of edgesTo.values()) {
edge.displayEdge();
}
}
// Ensure rerender
this.flowchart?.render();
}
withFlowchart() {
this.scene?.withSectionFlowchart();
this.flowchart = this.scene?.lastFlowchart;
if (this.editable) {
this.controlDoc = new Document('WDGControl', this.flowchart.box.el, { prepend: true });
this.editorDoc = new Document('WDGEditor', this.flowchart.box.el);
this.errorDoc = new Document('WDGErrors', this.flowchart.box.el);
this.resetEditor();
}
return this;
}
prepareControlDoc() {
const form = this.controlDoc.form({ name: 'controlForm' }).lastElement;
const { subForm: graphPropertiesForm } = form.subForm({ name: 'graphPropsForm' }).lastItem;
graphPropertiesForm.flex()
.textField({ name: 'name', label: 'Graph name', defaultValue: this.name })
.submit({
name: 'Save',
cb: (({ form: { value: { name } } }) => {
this.name = name;
}),
});
const { subForm: exportImportForm } = form.subForm({ name: 'exportImportForm' }).lastItem;
exportImportForm.flex()
.button({
name: 'Export',
cb: () => {
const a = window.document.createElement('a');
const json = JSON.stringify(this.toJSON(), null, 2);
const currentTime = Math.floor(new Date().getTime() / 1000);
a.href = `data:attachment/text,${encodeURI(json)}`;
a.target = '_blank';
a.download = `wdg_${this.name}_${currentTime}.json`;
a.click();
},
})
.fileInput({
name: 'Import',
cb: ({ input: { files: [file] } }) => {
const reader = new FileReader();
reader.onload = ({ target: { result: text } }) => {
console.log('imported file', { file });
// this.flowchart?.log(`%% Imported file ${file}`)
const data = JSON.parse(text);
this.fromJSON(data);
};
reader.readAsText(file);
},
});
}
resetEditor() {
this.editorDoc.clear();
this.controlDoc.clear();
this.prepareControlDoc();
Vertex.prepareEditorDocument(this, this.editorDoc);
}
addVertex(type, id, data, label, options) {
// Supports simple case of auto-incremented numeric ids
if (typeof id === 'object') {
@ -186,7 +61,6 @@ export class WeightedDirectedGraph {
}
const vertex = new Vertex(this, type, id, data, { ...options, label });
this.vertices.set(id, vertex);
vertex.displayVertex();
return vertex;
}
@ -229,7 +103,6 @@ export class WeightedDirectedGraph {
}
const edgeKey = Edge.getKey(edge);
edges.set(edgeKey, edge);
edge.displayEdgeNode();
return edge;
}
@ -239,15 +112,9 @@ export class WeightedDirectedGraph {
if (this.getEdge(type, from, to)) {
throw new Error(`Edge ${type} from ${from.id} to ${to.id} already exists`);
}
const existingEdges = this.getEdges(null, from, to);
const edge = this.setEdgeWeight(type, from, to, weight, data, options);
from.edges.from.push(edge);
to.edges.to.push(edge);
if (existingEdges.length) {
edge.displayEdgeNode();
} else {
edge.displayEdge();
}
return edge;
}

View File

@ -50,6 +50,12 @@ a:visited {
left: 12em;
top: -0.5em;
}
.fixed {
position: fixed;
}
.absolute {
position: absolute;
}
svg {
width: 800px;
}
@ -107,3 +113,21 @@ span.small {
white-space: nowrap;
width: 1px;
}
.force-directed-graph {
margin: 20px;
}
.force-directed-graph > canvas {
position: absolute;
}
.force-directed-graph > .box {
border: 1px hsl(195.4545454545, 4%, 39.4117647059%) solid;
color: #b6b6b6;
text-align: center;
padding: 4px;
}
.force-directed-graph > .vertex {
background-color: #216262;
}
.force-directed-graph > .edge {
background-color: #2a5b6c;
}

View File

@ -15,18 +15,19 @@
For more information please see the <a href="https://daogovernanceframework.com/wiki/DAO_Governance_Framework">DGF
Wiki</a>.
</p>
<p>
The code for this site is available in <a
href="https://gitlab.com/dao-governance-framework/science-publishing-dao/-/tree/main/forum-network/src">GitLab</a>.
</p>
<h2>Tools</h2>
<ul>
<li><a href="./wdg-editor">Weighted Directed Graph Editor</a></li>
<li><a href="./schema-editor">Schema Editor</a></li>
</ul>
<h2>Example Scenarios</h2>
<p>
Below are example scenarios with various assertions covering features of our reputation system.
</p>
<p>
The code for this site is available in <a
href="https://gitlab.com/dao-governance-framework/science-publishing-dao/-/tree/main/forum-network/src">GitLab</a>.
</p>
<ul>
<li><a href="./tests/validation-pool.test.html">Validation Pool</a></li>
<li><a href="./tests/availability.test.html">Availability + Business</a></li>
@ -66,6 +67,7 @@
<li><a href="./tests/mocha.test.html">Mocha</a></li>
<li><a href="./tests/input.test.html">Input</a></li>
<li><a href="./tests/document.test.html">Document</a></li>
<li><a href="./tests/force-directed.test.html">Force-Directed Graph</a></li>
</ul>
<ul>
<h4><a href="./tests/all.test.html">All</a></h4>

View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<head>
<title>Schema Editor</title>
<link type="text/css" rel="stylesheet" href="../index.css" />
<script type="module" src="./index.js"></script>
<script defer data-domain="dgov.io" src="https://plausible.io/js/script.js"></script>
</head>
<body>
<h2><a href="../">DGF Prototype</a></h2>
<h1>Schema Editor</h1>
<div id="scene"></div>
</body>

View File

@ -0,0 +1,9 @@
import { Box } from '../classes/display/box.js';
import { Scene } from '../classes/display/scene.js';
import { SchemaEditor } from '../classes/supporting/schema.js';
const rootElement = document.getElementById('scene');
const rootBox = new Box('rootBox', rootElement).flex();
window.disableSceneControls = true;
window.scene = new Scene('Schema Editor', rootBox);
window.schemaEditor = new SchemaEditor('new', window.scene);

View File

@ -40,6 +40,7 @@
<script type="module" src="./scripts/forum/forum11.test.js"></script>
<script type="module" src="./scripts/input.test.js"></script>
<script type="module" src="./scripts/document.test.js"></script>
<script type="module" src="./scripts/force-directed.test.js"></script>
<script defer class="mocha-init">
mocha.setup({
ui: 'bdd',

View File

@ -0,0 +1,28 @@
<!DOCTYPE html>
<head>
<title>Force-Directed Graph test</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="https://unpkg.com/mocha/mocha.css" />
<link type="text/css" rel="stylesheet" href="../index.css" />
<script defer data-domain="dgov.io" src="https://plausible.io/js/script.js"></script>
</head>
<body>
<h2><a href="../">DGF Prototype</a></h2>
<div id="mocha"></div>
<div id="scene"></div>
</body>
<script src="https://cdnjs.cloudflare.com/ajax/libs/radash/10.7.0/radash.js"
integrity="sha512-S207zKWG3iqXqe6msO7/Mr8X3DzzF4u8meFlokHjGtBPTGUhgzVo0lpcqEy0GoiMUdcoct+H+SqzoLsxXbynzg=="
crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://unpkg.com/chai/chai.js"></script>
<script src="https://unpkg.com/mocha/mocha.js"></script>
<script defer class="mocha-init">
mocha.setup({
ui: 'bdd',
});
chai.should();
</script>
<script type="module" src="./scripts/force-directed.test.js"></script>

View File

@ -6,6 +6,7 @@ import { Public } from '../../classes/actors/public.js';
import { PostContent } from '../../classes/supporting/post-content.js';
import { delayOrWait } from '../../classes/display/scene-controls.js';
import { mochaRun } from '../../util/helpers.js';
import { DEFAULT_REP_TOKEN_TYPE_ID } from '../../util/constants.js';
const DELAY_INTERVAL = 100;
const POOL_DURATION = 200;
@ -32,7 +33,7 @@ const setup = async () => {
scene.withTable();
dao = new DAO('DGF', scene);
await dao.setDisplayValue('total rep', () => dao.reputation.getTotal());
await dao.setDisplayValue('total rep', () => dao.reputation.getTotal(DEFAULT_REP_TOKEN_TYPE_ID));
experts = [];
@ -58,7 +59,7 @@ const setup = async () => {
await pool1.evaluateWinningConditions();
await delayOrWait(DELAY_INTERVAL);
dao.reputation.valueOwnedBy(experts[0].reputationPublicKey).should.equal(10);
dao.reputation.valueOwnedBy(experts[0].reputationPublicKey, DEFAULT_REP_TOKEN_TYPE_ID).should.equal(10);
const { pool: pool2 } = await experts[1].submitPostWithFee(
new PostContent({ hello: 'to you as well' })
@ -77,8 +78,8 @@ const setup = async () => {
await pool2.evaluateWinningConditions();
await delayOrWait(DELAY_INTERVAL);
dao.reputation.valueOwnedBy(experts[0].reputationPublicKey).should.equal(15);
dao.reputation.valueOwnedBy(experts[1].reputationPublicKey).should.equal(5);
dao.reputation.valueOwnedBy(experts[0].reputationPublicKey, DEFAULT_REP_TOKEN_TYPE_ID).should.equal(15);
dao.reputation.valueOwnedBy(experts[1].reputationPublicKey, DEFAULT_REP_TOKEN_TYPE_ID).should.equal(5);
};
const getActiveWorker = async () => {

View File

@ -0,0 +1,204 @@
import { Box } from '../../classes/display/box.js';
import { ForceDirectedGraph } from '../../classes/display/force-directed.js';
import { Rectangle, Vector } from '../../classes/supporting/geometry/index.js';
import { overlapRepulsionForce, targetRadiusForce } from '../../classes/display/pairwise-forces.js';
import { delayOrWait } from '../../classes/display/scene-controls.js';
import { Scene } from '../../classes/display/scene.js';
import { DEFAULT_OVERLAP_BUFFER, EPSILON } from '../../util/constants.js';
import { mochaRun } from '../../util/helpers.js';
const rootElement = document.getElementById('scene');
const rootBox = new Box('rootBox', rootElement).flex();
window.scene = new Scene('WDG test', rootBox);
describe('Force-Directed Graph', function tests() {
this.timeout(0);
let graph;
before(() => {
graph = (window.graph = new ForceDirectedGraph('test1', window.scene.middleSection.el, {
width: 1200, height: 900,
}));
graph.addVertex('v1', 'box1');
});
it('rectangle should be a polygon with 4 vertices', () => {
const rect = new Rectangle([0, 0], [1, 1]);
rect.vertices[0].should.eql([0, 0]);
rect.vertices[1].should.eql([1, 0]);
rect.vertices[2].should.eql([1, 1]);
rect.vertices[3].should.eql([0, 1]);
});
it('should measure extent of overlap', () => {
const rects = [
new Rectangle([0, 0], [1, 1]),
new Rectangle([0.5, 0], [1, 1]),
new Rectangle([0, 0.5], [1, 1]),
new Rectangle([0.5, 0.5], [1, 1]),
new Rectangle([0, 1], [1, 1]),
new Rectangle([0, 0], [1, 2]),
new Rectangle([0, 0], [0.5, 0.5]),
new Rectangle([2, 2], [1, 1]),
];
rects[0].doesOverlap(rects[1]).should.eql(1);
rects[0].doesOverlap(rects[2]).should.eql(1);
rects[0].doesOverlap(rects[3]).should.eql(0.5);
rects[0].doesOverlap(rects[4]).should.eql(0);
rects[0].doesOverlap(rects[5]).should.eql(1);
rects[0].doesOverlap(rects[6]).should.eql(1);
rects[0].doesOverlap(rects[7]).should.eql(0);
rects[1].doesOverlap(rects[0]).should.eql(1);
rects[2].doesOverlap(rects[0]).should.eql(1);
rects[3].doesOverlap(rects[0]).should.eql(0.5);
rects[4].doesOverlap(rects[0]).should.eql(0);
rects[5].doesOverlap(rects[0]).should.eql(1);
rects[6].doesOverlap(rects[0]).should.eql(1);
rects[7].doesOverlap(rects[0]).should.eql(0);
});
it('overlapping boxes should repel', () => {
const rect1 = new Rectangle([0, 0], [1, 1]);
const rect2 = new Rectangle([0, 0], [1, 2]);
rect1.center.should.eql([0.5, 0.5]);
rect2.center.should.eql([0.5, 1]);
const force1 = overlapRepulsionForce(rect1, rect2, 10);
force1.should.eql([0, 10]);
});
it('adjacent boxes should repel', () => {
const rect1 = new Rectangle([0, 0], [1, 1]);
const rect2 = new Rectangle([0, 1], [1, 1]);
const rect3 = new Rectangle([0, DEFAULT_OVERLAP_BUFFER / 2 + 1], [1, 1]);
const rect4 = new Rectangle([0, DEFAULT_OVERLAP_BUFFER + 1], [1, 1]);
const rect5 = new Rectangle([DEFAULT_OVERLAP_BUFFER + 1, DEFAULT_OVERLAP_BUFFER + 1], [1, 1]);
rect1.doesOverlap(rect2).should.eql(0);
rect1.doesOverlap(rect3).should.eql(0);
rect1.doesOverlap(rect4).should.eql(0);
const force1 = overlapRepulsionForce(rect1, rect2, 10);
force1[0].should.eql(0);
force1[1].should.be.within(9.99, 10.01);
const force2 = overlapRepulsionForce(rect1, rect3, 10);
force2[0].should.eql(0);
force2[1].should.be.within(4.99, 5.01);
const force3 = overlapRepulsionForce(rect1, rect4, 10);
force3[0].should.eql(0);
force3[1].should.be.within(-0.01, 0.01);
const force4 = overlapRepulsionForce(rect1, rect5, 10);
force4[0].should.be.within(-0.01, 0.01);
force4[1].should.be.within(-0.01, 0.01);
});
it('boxes at target radius should have no net force', () => {
const rect1 = new Rectangle([0, 0], [1, 1]);
const rect2 = new Rectangle([11, 0], [1, 1]);
rect1.center.should.eql([0.5, 0.5]);
rect2.center.should.eql([11.5, 0.5]);
const force = targetRadiusForce(rect1, rect2, 10);
force[0].should.be.within(-0.01, 0.01);
force[1].should.be.within(-0.01, 0.01);
});
it('can construct a unit vector', () => {
Vector.unitVector(0, 2).should.eql([1, 0]);
Vector.unitVector(1, 2).should.eql([0, 1]);
});
it('normalized vector should have length = 1', () => {
const v = Vector.from([2, 0]);
const u = v.normalize();
u.magnitude.should.be.within(1 - EPSILON, 1 + EPSILON);
});
it('random unit vector should have length = 1', () => {
const u = Vector.randomUnitVector(2);
u.magnitude.should.be.within(1 - EPSILON, 1 + EPSILON);
});
it('can compute intersection of line with rectangle', () => {
const rect = new Rectangle([100, 100], [100, 100]);
{
const intersect = rect.lineIntersect([0, 150], [1, 0]);
intersect[0].should.be.within(99.99, 100.01);
intersect[1].should.eql(150);
}
{
const intersect = rect.lineIntersect([150, 0], [0, 1]);
intersect[0].should.eql(150);
intersect[1].should.be.within(99.99, 100.01);
}
{
const intersect = rect.lineIntersect([0, 0], [1, 1]);
intersect[0].should.be.within(99.99, 100.01);
intersect[1].should.be.within(99.99, 100.01);
}
{
const intersect = rect.lineIntersect([0, 150], [-1, 0]);
(intersect === null).should.be.true;
}
});
it('can add a second box to the graph', async () => {
await delayOrWait(500);
const v = graph.addVertex('v1', 'box2');
v.setProperty('prop', 'value');
});
it('can add an edge to the graph', async () => {
await delayOrWait(500);
graph.addEdge('e1', 'box1', 'box2', 1);
});
it('runs until reaching equilibrium', async () => {
await graph.runUntilEquilibrium();
});
it('can add 10 random nodes', async () => {
for (let i = 3; i <= 10; i++) {
await delayOrWait(200);
const v = graph.addVertex('v2', `box${i}`);
v.setProperty('prop', 'value');
}
});
it('can add 10 random edges', async () => {
await delayOrWait(500);
for (let i = 1; i <= 10; i++) {
let from;
let to;
do {
from = graph.nodes[Math.floor(Math.random() * graph.nodes.length)];
to = graph.nodes[Math.floor(Math.random() * graph.nodes.length)];
} while (from.name === to.name && !graph.getEdge('one', from.name, to.name));
await delayOrWait(200);
graph.addEdge('one', from.name, to.name, Math.floor(Math.random() * 100) / 100);
}
});
it.skip('can add 10 more random nodes', async () => {
for (let i = 11; i <= 20; i++) {
await delayOrWait(200);
const v = graph.addVertex('v3', `box${i}`);
v.setProperty('prop', Math.random() * 10000);
}
});
it('can add 10 more random edges', async () => {
await delayOrWait(500);
for (let i = 11; i <= 20; i++) {
let from;
let to;
do {
from = graph.nodes[Math.floor(Math.random() * graph.nodes.length)];
to = graph.nodes[Math.floor(Math.random() * graph.nodes.length)];
} while (from.name === to.name && !graph.getEdge('two', from.name, to.name));
await delayOrWait(200);
graph.addEdge('two', from.name, to.name, Math.floor(Math.random() * 100) / 100);
}
});
});
mochaRun();

View File

@ -4,6 +4,7 @@ import { Expert } from '../../classes/actors/expert.js';
import { PostContent } from '../../classes/supporting/post-content.js';
import { DAO } from '../../classes/dao/dao.js';
import { delayOrWait } from '../../classes/display/scene-controls.js';
import { DEFAULT_REP_TOKEN_TYPE_ID } from '../../util/constants.js';
export class ForumTest {
constructor(options) {
@ -86,6 +87,6 @@ export class ForumTest {
await this.newExpert();
await this.dao.addComputedValue('total value', () => this.dao.reputation.getTotal());
await this.dao.addComputedValue('total rep', () => this.dao.reputation.getTotal(DEFAULT_REP_TOKEN_TYPE_ID));
}
}

View File

@ -1,3 +1,4 @@
import { DEFAULT_REP_TOKEN_TYPE_ID } from '../../../util/constants.js';
import { mochaRun } from '../../../util/helpers.js';
import { ForumTest } from '../forum.test-util.js';
@ -34,9 +35,9 @@ describe('Forum', function tests() {
await forumTest.addPost(authors, 10);
forum.getPost(posts[0]).value.should.equal(10);
dao.reputation.valueOwnedBy(experts[0].reputationPublicKey).should.equal(5);
dao.reputation.valueOwnedBy(experts[1].reputationPublicKey).should.equal(2.5);
dao.reputation.valueOwnedBy(experts[2].reputationPublicKey).should.equal(2.5);
dao.reputation.valueOwnedBy(experts[0].reputationPublicKey, DEFAULT_REP_TOKEN_TYPE_ID).should.equal(5);
dao.reputation.valueOwnedBy(experts[1].reputationPublicKey, DEFAULT_REP_TOKEN_TYPE_ID).should.equal(2.5);
dao.reputation.valueOwnedBy(experts[2].reputationPublicKey, DEFAULT_REP_TOKEN_TYPE_ID).should.equal(2.5);
});
});
});

View File

@ -1,3 +1,4 @@
import { DEFAULT_REP_TOKEN_TYPE_ID } from '../../../util/constants.js';
import { mochaRun } from '../../../util/helpers.js';
import { ForumTest } from '../forum.test-util.js';
@ -33,9 +34,9 @@ describe('Forum', function tests() {
await forumTest.addPost(authors, 10);
forum.getPost(posts[0]).value.should.equal(10);
dao.reputation.valueOwnedBy(experts[0].reputationPublicKey).should.equal(5);
dao.reputation.valueOwnedBy(experts[1].reputationPublicKey).should.equal(5);
dao.reputation.valueOwnedBy(experts[2].reputationPublicKey).should.equal(0);
dao.reputation.valueOwnedBy(experts[0].reputationPublicKey, DEFAULT_REP_TOKEN_TYPE_ID).should.equal(5);
dao.reputation.valueOwnedBy(experts[1].reputationPublicKey, DEFAULT_REP_TOKEN_TYPE_ID).should.equal(5);
dao.reputation.valueOwnedBy(experts[2].reputationPublicKey, DEFAULT_REP_TOKEN_TYPE_ID).should.equal(0);
});
it('Post2 with two authors, one shared with Post1', async () => {
@ -46,9 +47,9 @@ describe('Forum', function tests() {
await forumTest.addPost(authors, 10);
forum.getPost(posts[0]).value.should.equal(10);
dao.reputation.valueOwnedBy(experts[0].reputationPublicKey).should.equal(5);
dao.reputation.valueOwnedBy(experts[1].reputationPublicKey).should.equal(10);
dao.reputation.valueOwnedBy(experts[2].reputationPublicKey).should.equal(5);
dao.reputation.valueOwnedBy(experts[0].reputationPublicKey, DEFAULT_REP_TOKEN_TYPE_ID).should.equal(5);
dao.reputation.valueOwnedBy(experts[1].reputationPublicKey, DEFAULT_REP_TOKEN_TYPE_ID).should.equal(10);
dao.reputation.valueOwnedBy(experts[2].reputationPublicKey, DEFAULT_REP_TOKEN_TYPE_ID).should.equal(5);
});
});
});

View File

@ -1,5 +1,6 @@
import { Box } from '../../classes/display/box.js';
import { Scene } from '../../classes/display/scene.js';
import { WDGDiagram } from '../../classes/display/wdg-mermaid-ui.js';
import { WeightedDirectedGraph } from '../../classes/supporting/wdg.js';
import { mochaRun } from '../../util/helpers.js';
@ -13,7 +14,7 @@ describe('Weighted Directed Graph', function tests() {
let graph;
before(() => {
graph = (window.graph = new WeightedDirectedGraph('test1', window.scene)).withFlowchart();
graph = (window.graph = new WDGDiagram('test1', window.scene)).withFlowchart();
graph.addVertex('v1', {});
graph.addVertex('v1', {});
@ -150,20 +151,4 @@ describe('Weighted Directed Graph', function tests() {
});
});
describe('Editable', () => {
let graph;
it('should be editable', () => {
graph = (window.graph2 = new WeightedDirectedGraph('test2', window.scene, { editable: true })).withFlowchart();
graph.addVertex('v1', {});
graph.addVertex('v2', {});
graph.addVertex('v3', {});
graph.addEdge('e1', 2, 1, 1);
graph.addEdge('e2', 1, 0, 0.5);
graph.addEdge('e3', 2, 0, 0.25);
});
});
mochaRun();

View File

@ -1,14 +1,28 @@
export const EPSILON = 2.23e-16;
export const INCINERATOR_ADDRESS = '0';
export const EdgeTypes = {
CITATION: 'citation',
BALANCE: 'balance',
AUTHOR: 'author',
};
export const VertexTypes = {
POST: 'post',
AUTHOR: 'author',
};
export const ARROWHEAD_LENGTH = 12;
export const ARROWHEAD_WIDTH = 6;
export const CENTRAL_RESTORING_FORCE = 100;
export const DEFAULT_OVERLAP_BUFFER = 100;
export const DEFAULT_OVERLAP_FORCE = 400;
export const DEFAULT_REP_TOKEN_TYPE_ID = 0;
export const DEFAULT_TARGET_RADIUS = 200;
export const DEFAULT_TIME_STEP = 0.1;
export const DEFAULT_DISTANCE_FACTOR = 0.5;
export const EPSILON = 2.23e-16;
export const INCINERATOR_ADDRESS = '0';
export const MAXIMUM_STEPS = 500;
export const MINIMUM_FORCE = 1;
export const MINIMUM_VELOCITY = 1;
export const MINIMUM_STEPS = 10;
export const OVERLAP_THRESHOLD_RANDOMIZE = 0.5;
export const TIME_DILATION_FACTOR = 500;
export const TRANSLATION_VELOCITY_FACTOR = 0.2;
export const VISCOSITY_FACTOR = 0.7;

View File

@ -1,9 +1,9 @@
import { Box } from '../classes/display/box.js';
import { Scene } from '../classes/display/scene.js';
import { WeightedDirectedGraph } from '../classes/supporting/wdg.js';
import { WDGDiagram } from '../classes/display/wdg-mermaid-ui.js';
const rootElement = document.getElementById('scene');
const rootBox = new Box('rootBox', rootElement).flex();
window.disableSceneControls = true;
window.scene = new Scene('WDG Editor', rootBox);
window.graph = new WeightedDirectedGraph('new', window.scene, { editable: true }).withFlowchart();
window.graph = new WDGDiagram('new', window.scene, { editable: true }).withFlowchart();