Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New: Add properties setters and getters #93

Merged
merged 33 commits into from
Mar 27, 2024

Conversation

AlexIchenskiy
Copy link

Resolves #88

This PR introduces setters, patchers, and getters for node/edge properties, along with implementing the Observer pattern to invoke view rerender upon property changes. The API has been significantly changed. Instead of writing unsafe code like

orb.data.getNodeById(0).property = x;

we now use

orb.data.getNodeById(0).setProperty(x);

and

orb.data.getNodeById(0).patchProperty(x);

Accessing properties is also modified, from

orb.data.getNodeById(0).x;

to

orb.data.getNodeById(0).getX();

Documentation needs to be updated accordingly. However, I believe it's more appropriate to do a complete rewrite upon the 1.0.0 release. Additionally, I want to implement tests after discussing all the details.

This PR is marked as a draft because I feel there are important aspects to double-check and review. However, it's open for all sorts of comments and advice!

@AlexIchenskiy AlexIchenskiy added this to the v1.0.0 milestone Feb 28, 2024
@AlexIchenskiy AlexIchenskiy self-assigned this Feb 28, 2024
@AlexIchenskiy
Copy link
Author

Tests are failing because of the usage of the structuredClone() function, which was introduced in Node.js 17.0.0, while our tests are running on Node.js v16.20.2. Is it ok to update the Node.js version in the package.json to >=17.0.0, or should I use some custom function for deep cloning objects?

src/models/observer.ts Outdated Show resolved Hide resolved
src/models/node.ts Outdated Show resolved Hide resolved
| INodeCoordinates
| INodeMapCoordinates
| INodePosition
| ((node: INode<N, E>) => INodeCoordinates | INodeMapCoordinates | INodePosition),
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should I merge these interfaces somehow? I was considering assigning a type to nodes (for map view and regular view), but I'm unsure if it's necessary since users can use any value from the node data for lng and lat. Is there a better approach to setting the position for map view nodes maybe?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check the orb usage for the map, you will see that the user needs to say how lat and long are extracted for each node. That is where user has ability to remove positions (not returning anything, etc.).

https://github.com/memgraph/orb/blob/main/examples/example-graph-on-map.html#L49

So LAT and LNG should be removed from these, first because it changes the data - we should never change the data because that is owner by the user, and second, user can have abc and def for his latitude and longitude information - in the map callback he would just do this:

orb.setView((context) => new Orb.MapView(context, {
  getGeoPosition: (node) => ({ lat: node.data.abc, lng: node.data.def, }),
}));

To recap, map is not using these positions.

position: IEdgePosition;
style: IEdgeStyle;
state: number;
export interface IEdge<N extends INodeBase, E extends IEdgeBase> extends ISubject {
readonly id: any;
readonly offset: number;
readonly start: any;
readonly startNode: INode<N, E>;
readonly end: any;
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was considering rewriting the values to use getters for consistency, but some are required in the IEdgeBase interface (such as id, start, end), which is also required in topology.ts. While I believe it's a good idea to use getters for consistency, it seems unnatural given the existing interface requirements. Is it fine to have a combination of TypeScript getters (e.g., get x) and regular getters (e.g., getX), or should I consider modifying the API further?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I am not sure. These are already readonly so user can just read them and there is no way to set those except creating a new edge or node. I am leaving the decision to you, if you wish and you think it will be more consistent, feel free to have it consistently. But Base class will still need to have those simple fields because that's how user creates nodes and edges (as JS objects).

src/models/edge.ts Outdated Show resolved Hide resolved
@tonilastre
Copy link
Contributor

Tests are failing because of the usage of the structuredClone() function, which was introduced in Node.js 17.0.0, while our tests are running on Node.js v16.20.2. Is it ok to update the Node.js version in the package.json to >=17.0.0, or should I use some custom function for deep cloning objects?

Yep, we should update Node to some newer version for sure!

Copy link
Contributor

@tonilastre tonilastre left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is going in a great direction!

I still need to check 3 of your comments, so stay tuned for responses.

src/models/edge.ts Show resolved Hide resolved
src/renderer/canvas/node.ts Outdated Show resolved Hide resolved
src/models/style.ts Outdated Show resolved Hide resolved
src/simulator/factory.ts Show resolved Hide resolved
src/models/edge.ts Outdated Show resolved Hide resolved
src/models/node.ts Outdated Show resolved Hide resolved
src/models/node.ts Outdated Show resolved Hide resolved
src/views/orb-map-view.ts Outdated Show resolved Hide resolved
Comment on lines 236 to 238
update(): void {
this.render();
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, this is maybe a bit tricky, because on external API (orb view), there is an .update() call because of an observer. Is there a way to handle this better? Would an observer with a callback do the trick or?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there anything we can do there? Do you have any ideas?

Copy link
Contributor

@cizl cizl Mar 11, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you Toni maybe have an idea how to implement this?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently I am seeing two ideas, but there can be more for sure:

  1. Have a proxy class that is an actual listener, but also gives you a callback way to respond to updates. Then view can initiate that proxy class where update will be called, and update calls a callback that is registered privately from the view. Then update is not exposed - easier
  2. Add callback to the observer pattern, so listener can either have a callback or extending the update to get calls for notifications - harder

src/models/graph.ts Outdated Show resolved Hide resolved
.github/workflows/build.yml Outdated Show resolved Hide resolved
package.json Outdated Show resolved Hide resolved
src/models/edge.ts Outdated Show resolved Hide resolved
Comment on lines 236 to 238
update(): void {
this.render();
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there anything we can do there? Do you have any ideas?

src/utils/observer.utils.ts Outdated Show resolved Hide resolved
src/utils/object.utils.ts Outdated Show resolved Hide resolved
| INodeMapCoordinates
| INodePosition
| ((node: INode<N, E>) => INodeCoordinates | INodeMapCoordinates | INodePosition),
isInner?: boolean,
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have to use the second argument here because sometimes we don't need to notify listeners on set position (e.g., on SIMULATION_PROGRESS in orb-view.ts because position handling is done through the simulation). However, this approach does not seem to be very clean. Do you have any ideas on how I could make it better?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we decide to leave it here, perhaps a better naming option would be isInnerCall? I will also update the interface accordingly when we define it :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aha. Angular uses dispatch: false for those kind of events. isInnerCall doesn't really say what you described so I would go into the direction such as: isNotifySkipped or isDispatchSkipped, isNotifyIgnored. In general, use the name that describes what you described to me, you don't want to notify listeners. And the default should be false or not existing object, that's why I used skipped or Ignored. Feel free to come up with a name with such constraints.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, this should be an object, optional options?.

@@ -312,6 +312,27 @@ export class D3SimulatorEngine extends Emitter<D3SimulatorEvents> {
}
}

patchData(data: Partial<ISimulationGraph>) {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it somehow possible to reduce the code redundancy between the patchData and _initializeNewData functions? They are essentially performing patch and set operations, but I'm not sure if it's possible to reduce the code repetition here without overcomplicating things.

fx: data.x,
fy: data.y,
id: data.id,
},
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should I create and use some sort of getStickyNode function here instead of directly writing these properties?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see a problem writing it directly, but maybe @cizl has more to say here?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is fine. I would write a utility function if this starts to get repeated.

But on the other hand, when can this be called? Will it interfere with the physics functionality that depends on fx and fy? Is the nodes in the data always guaranteed to be sticky?

Comment on lines 1 to 7
import { INodeCoordinates, INodeMapCoordinates, INodePosition } from '../models/node';

export type IObserverDataPayload = INodePosition | INodeCoordinates | INodeMapCoordinates;

export interface IObserver {
update(data?: IObserverDataPayload): void;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uh oh, this is not generic anymore because you introduced local models (Position, Coordinates, NodeMapCoordinates) to the util ones. :D

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am considering adding parameterization for data by including parameters that represent the data that can be sent (similar to this payload but specified in the parameters of each implementation). Do you maybe have any better ideas?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have created an issue #97 for this where we can discuss it further :)

@@ -282,7 +286,7 @@ export class OrbView<N extends INodeBase, E extends IEdgeBase> implements IOrbVi
for (let i = 0; i < nodes.length; i++) {
const position = this._settings.getPosition(nodes[i]);
if (position) {
nodes[i].position = { id: nodes[i].id, ...position };
nodes[i].setPosition({ id: nodes[i].getId(), ...position }, true);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to previous comments, seeing this true as second argument makes code harder to read. Having an object instead will work better because it will be more clear enough what true is all about.

Copy link
Contributor

@cizl cizl Mar 14, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tonilastre When passing objects as inputs to functions, how do we handle sane defaults?
When using function arguments we can put some defaults, but when using objects, we need to specify each and every property.

On a call earlier this week we discussed having a default object in that case, and passing a Partial<IInputParams> as an input to override the "defaults". Does this make sense? How is this usually handled in practice?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It depends on the options. In some other comment, I suggested what the options should be for this one where empty options would mean normal mail flow. Only in exception, the options can be added.

If that is not the case, then yea, the function should define defaults internally and merge it with the input options.

fx: data.x,
fy: data.y,
id: data.id,
},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see a problem writing it directly, but maybe @cizl has more to say here?

@@ -120,3 +120,11 @@ const copyPlainObject = <T>(obj: Record<string, T>): Record<string, T> => {
});
return newObject;
};

export const patchProperties = <T>(target: T, source: T): void => {
const keys = Object.keys(source as Object);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure, but if you say here that these keys are (keyof T)[] or something similar, you won't need this monster of as X on line 128.

data.nodes = this._fixDefinedNodes(data.nodes);
const nodeIds = this._nodes.map((node) => node.id);
for (let i = 0; i < data.nodes.length; i += 1) {
if (nodeIds.includes(data.nodes[i].id)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Save indents. Have reverse if here and continue. The main code will have one less indent - it is easier to read code then.

Comment on lines 197 to 201
this.setData({ ...data, lng: undefined });
}

if ('lat' in this._data) {
this.setData({ ...data, lat: undefined });
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bit tricky because you are updating user's data. I understand that you wanted to handle both map and regular view, but user should handle the map positions with the callback that is on the map view. These x, y positions are not even used by the simulator on the map.

To recap, I would remove this because it changes the user's data.

Comment on lines 460 to 463
setPosition(position: INodeCoordinates | INodeMapCoordinates | INodePosition, isInner?: boolean): void;
setPosition(
callback: (node: INode<N, E>) => INodeCoordinates | INodeMapCoordinates | INodePosition,
isInner?: boolean,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Btw, these functions in the interface have name call instead of isInner. It would be great to have them consistent.

| INodeCoordinates
| INodeMapCoordinates
| INodePosition
| ((node: INode<N, E>) => INodeCoordinates | INodeMapCoordinates | INodePosition),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check the orb usage for the map, you will see that the user needs to say how lat and long are extracted for each node. That is where user has ability to remove positions (not returning anything, etc.).

https://github.com/memgraph/orb/blob/main/examples/example-graph-on-map.html#L49

So LAT and LNG should be removed from these, first because it changes the data - we should never change the data because that is owner by the user, and second, user can have abc and def for his latitude and longitude information - in the map callback he would just do this:

orb.setView((context) => new Orb.MapView(context, {
  getGeoPosition: (node) => ({ lat: node.data.abc, lng: node.data.def, }),
}));

To recap, map is not using these positions.

| INodeMapCoordinates
| INodePosition
| ((node: INode<N, E>) => INodeCoordinates | INodeMapCoordinates | INodePosition),
isInner?: boolean,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aha. Angular uses dispatch: false for those kind of events. isInnerCall doesn't really say what you described so I would go into the direction such as: isNotifySkipped or isDispatchSkipped, isNotifyIgnored. In general, use the name that describes what you described to me, you don't want to notify listeners. And the default should be false or not existing object, that's why I used skipped or Ignored. Feel free to come up with a name with such constraints.

| INodeMapCoordinates
| INodePosition
| ((node: INode<N, E>) => INodeCoordinates | INodeMapCoordinates | INodePosition),
isInner?: boolean,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, this should be an object, optional options?.

@@ -89,6 +90,7 @@ export class OrbMapView<N extends INodeBase, E extends IEdgeBase> implements IOr
this.render();
}
},
listeners: [this._update],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be great to add a comment why this line and not this. Same for map view.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added this comment in observer.utils.ts so we don't have to add it everywhere. Is it fine, or should I add it to the view files as well?

@AlexIchenskiy AlexIchenskiy marked this pull request as ready for review March 21, 2024 07:09
@AlexIchenskiy AlexIchenskiy merged commit 7e708a5 into new/simulator-fixes Mar 27, 2024
1 check passed
@AlexIchenskiy AlexIchenskiy deleted the new/add-setters-and-getters branch March 27, 2024 09:12
AlexIchenskiy added a commit that referenced this pull request Mar 27, 2024
* New: Add new simulator (#56)

* New: Add simulator scenarios for manual testing
* New: Refactor simulator (WIP)
* New: Add progress overlay, Update descriptions
* Fix: Introduce new simulator event, Fix main-thread behavior
* Fix: Rearrange class methods based on visibility
* Fix: Improve naming
* Chore(release): 0.2.0
* Fix: Tweak simulator, adjust API slightly
* Fix: Temporarily patch some physics behavior
* Fix: Adjust re-heating parameters
* Fix: tweak physics behavior -> immediately stop sim when disabling
* Chore: Remove the beta from release branches

---------

Co-authored-by: dlozic <davidlozic@gmail.com>

* New: Add zoom and recenter functions (#74)

* New: Add zoom and recenter functions

* Fix: Reduce excessive recentering (#75)

* Fix: Remove excessive recenterings
* Fix: Remove unused code

* Fix: New simulator (#92)

* Chore: Refactor naming

* New: Add new events

* Chore: Refactor code styling

* Docs: Remove unused flags

* Chore: Remove unused simulation functions

* Chore: Refactor view render function calls

* Chore: Add missing tests

* New: Add removal functions (#96)

* New: Add removal functions

* Fix: Add missing callback data

* Chore: Refactor remove return values

* Chore: Refactor remove function type usage

* Fix: Default settings for node placement (#98)

* New: Add properties setters and getters (#93)

* New: Add node properties setters

* New: Add edge properties setters

* New: Add properties getters

* New: Add patch for nodes and edges

* Fix: Make getters return copies

* Fix: Edge factory listeners copying

* Fix: Jest outdated tests

* Fix: Github actions node version

* Chore: Refactor observer interface

* Chore: Refactor node/edge constructor settings

* Chore: Refactor node/edge function grouping

* Chore: Refactor node/edge function grouping

* Fix: Listeners behaviour

* Chore: Refactor property copying

* Chore: Refactor subject implementation

* Fix: Set position behaviour on node drag

* Chore: Upgrade node version

* Chore: Refactor function type check

* Fix: Remove listener behaviour

* Chore: Refactor util naming

* Chore: Remove unused type assertion

* Chore: Refactor position setter options

* Chore: Refactor property patch function

* Fix: Set map node position behaviour

* Chore: Refactor simulator data patching

* Chore: Change observers to callbacks

* New: Add state setters with options (#95)

* New: Add state setters with options

* Chore: Remove leftover comments

* Chore: Refactor state setter logic

* Chore: Refactor state types

* Fix: Rename merged function usage

* Fix: Merged variable naming

* Chore: Fix tests

---------

Co-authored-by: dlozic <davidlozic@gmail.com>
Co-authored-by: Oleksandr Ichenskyi <55350107+AlexIchenskiy@users.noreply.github.com>
Co-authored-by: AlexIchenskiy <aichenskiy@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants