import random
import re
from importlib import resources
from typing import List, Optional
from .modular_page import Control, ModularPage, Prompt
from .timeline import MediaSpec
from .utils import is_valid_html5_id
[docs]
class GraphicMixin:
"""
A mix-in corresponding to a graphic panel.
Parameters
----------
id_
An identifier for the graphic, should be parsable as a valid variable name.
frames
List of :class:`psynet.graphic.Frame` objects.
These frames will be displayed in sequence to the participant.
dimensions
A list containing two numbers, corresponding to the x and y dimensions of the graphic.
The ratio of these numbers determines the aspect ratio of the graphic.
They define the coordinate system according to which objects are plotted.
However, the absolute size of the graphic is independent of the size of these numbers
(i.e. a 200x200 graphic occupies the same size on the screen as a 100x100 graphic).
viewport_width
The width of the graphic display, expressed as a fraction of the browser window's width.
The default value (0.6) means that the graphic occupies 60% of the window's width.
loop
Whether the graphic should loop back to the first frame once the last frame has finished.
media
Optional :class:`psynet.timeline.MediaSpec` object providing audio and image files to
be used within the graphic.
**kwargs
Additional parameters passed to parent classes.
"""
margin: str = "25px"
"CSS margin property for the graphic panel."
border_style: str = "solid"
"CSS border-style property for the graphic panel."
border_color: str = "#cfcfcf"
"CSS border-color property for the graphic panel."
border_width: str = "1px"
"CSS border-width property for the graphic panel."
def __init__(
self,
id_: str,
frames: "List[Frame]",
dimensions: List,
viewport_width: float = 0.6,
loop: bool = False,
media: Optional[MediaSpec] = None,
*args,
**kwargs,
):
super().__init__(*args, **kwargs)
self.id = id_
self.frames = frames
self.dimensions = dimensions
self.viewport_width = viewport_width
self.loop = loop
self._media = media
self.validate_id(id_)
self.validate_frames(frames)
self.validate_media(media)
def validate_media(self, media):
if not (media is None or isinstance(media, MediaSpec)):
raise ValueError(
"media must either be None or an instance of class MediaSpec"
)
if media is None:
media = MediaSpec()
media_ids = media.ids
for frame in self.frames:
if frame.audio_id is not None:
if frame.audio_id not in media_ids["audio"]:
raise ValueError(
f"audio '{frame.audio_id}' was missing from the media collection"
)
for obj in frame.objects:
if isinstance(obj, Image):
if obj.media_id not in media_ids["image"]:
raise ValueError(
f"image '{obj.media_id}' was missing from the media collection"
)
def validate_id(self, id_):
if not isinstance(id_, str):
raise ValueError("id_ must be a string")
if not is_valid_html5_id(id_):
raise ValueError("id_ must be a valid HTML5 id")
def validate_frames(self, frames):
for f in frames:
assert isinstance(f, Frame)
self.validate_object_ids(frames)
def validate_object_ids(self, frames):
persistent_objects = set()
for frame_id, frame in enumerate(frames):
frame_objects = set()
for o in frame.objects:
if o.id in frame_objects:
raise ValueError(
f"in Graphic {self.id}, Frame {frame_id}, duplicate object ID '{o.id}'"
)
if o.id in persistent_objects:
raise ValueError(
f"in Graphic {self.id}, Frame {frame_id}, tried to override persistent object '{o.id}'"
)
frame_objects.add(o.id)
if o.persist:
persistent_objects.add(o.id)
return True
@property
def media(self):
if self._media is None:
return MediaSpec()
return self._media
@property
def metadata(self):
return {
**super().metadata,
"dimensions": self.dimensions,
"viewport_width": self.viewport_width,
"loop": self.loop,
"n_frames": len(self.frames),
}
[docs]
class Frame:
"""
A :class:`psynet.graphic.Frame` defines an image to show to the participant.
Parameters
----------
objects
A list of :class:`psynet.graphic.Object` objects to include in the frame.
duration
The duration of the frame, in seconds. If ``None``, then the frame lasts forever.
audio_id
An optional ID for an audio file to play when the frame starts. This audio file must be provided in
the ``media`` slot of the parent :class:`psynet.graphic.GraphicMixin`.
activate_control_response
If ``True``, then activate responses for the page's :class:`psynet.modular_page.Control` object
once this frame is reached.
activate_control_submit
If ``True``, then enable response submission for the page's :class:`psynet.modular_page.Control` object
once this frame is reached.
"""
def __init__(
self,
objects: "List[GraphicObject]",
duration: Optional[float] = None,
audio_id: Optional[str] = None,
activate_control_response: bool = False,
activate_control_submit: bool = False,
):
assert (duration is None) or (duration >= 0)
self.objects = objects
self.audio_id = audio_id
self.duration = duration
self.activate_control_response = activate_control_response
self.activate_control_submit = activate_control_submit
[docs]
class Animation:
"""
An :class:`psynet.graphic.Animation` can be added to an :class:`psynet.graphic.Object` to provide motion.
Parameters
----------
final_attributes
The final set of attributes that the object should attain by the end of the animation.
Only animated attributes need to be mentioned.
For example, to say that the object should reach an x position of 60 by the end of the animation,
we write ``final_attributes={"x": 60}``.
See https://dmitrybaranovskiy.github.io/raphael/reference.html#Element.attr and https://www.w3.org/TR/SVG/
for available attributes.
Note that the x and y coordinates for circles and ellipses are called ``cx`` and ``cy``.
duration
The time that the animation should take to complete, in seconds.
easing
Determines the dynamics of the transition between initial and final attributes.
Permitted values are ``"linear"``, ``"ease-in"``, ``"ease-out"``, ``"ease-in-out"``,
``"back-in"``, ``"back-out"``, ``"elastic"``, and ``"bounce"``.
"""
def __init__(self, final_attributes: dict, duration: float, easing: str = "linear"):
self.final_attributes = final_attributes
self.duration = duration
self.easing = easing
[docs]
class GraphicObject:
"""
An object that is displayed as part of a :class:`psynet.graphic.Frame`.
Parameters
----------
id_
A unique identifier for the object. This should be parsable as a valid variable name.
click_to_answer
Whether clicking on the object constitutes a valid answer, thereby advancing to the next page.
persist
Whether the object should persist into successive frames.
In this case, the object must not share an ID with any objects in these successive frames.
attributes
A collection of attributes to give the object.
See https://dmitrybaranovskiy.github.io/raphael/reference.html#Element.attr for valid attributes,
and https://www.w3.org/TR/SVG/ for further details.
For example, one might write ``{"fill": "red", "opacity" = 0.5}``.
animations
A list of :class:`psynet.graphic.Animation` objects .
loop_animations
If ``True``, then the object's animations will be looped back to the beginning once they finish.
"""
def __init__(
self,
id_: str,
click_to_answer: bool = False,
persist: bool = False,
attributes: Optional[dict] = None,
animations: Optional[List] = None,
loop_animations: bool = False,
):
self.validate_id(id_)
self.id = id_
self.click_to_answer = click_to_answer
self.persist = persist
self.attributes = attributes
self.loop_animations = loop_animations
self.animations = None
self.animations_js = None
self.register_animations(animations)
def register_animations(self, animations):
if animations is None:
self.animations = []
elif isinstance(animations, Animation):
self.animations = [animations]
elif isinstance(animations, list):
self.animations = animations
else:
raise ValueError(
"animations must be None, or an object of class Animation, or a list of Animations"
)
self.animations_js = [
{
"index": index,
"finalAttributes": animation.final_attributes,
"duration": animation.duration,
"easing": animation.easing,
}
for index, animation in enumerate(self.animations)
]
def validate_id(self, id_):
if not isinstance(id_, str):
raise ValueError("id_ must be a string")
if not is_valid_html5_id(id_):
raise ValueError("id_ must be a valid HTML5 id")
@property
def js_init(self):
return []
@property
def js_edit(self):
return []
[docs]
class Text(GraphicObject):
"""
A text object.
Parameters
----------
id_
A unique identifier for the object.
text
Text to display.
x
x coordinate.
y
y coordinate.
**kwargs
Additional parameters passed to :class:`~psynet.graphic.GraphicObject`.
"""
def __init__(self, id_: str, text: str, x: int, y: int, **kwargs):
super().__init__(id_, **kwargs)
self.text = text
self.x = round(x)
self.y = round(y)
@property
def js_init(self):
escaped_text = self.text.replace("'", "\\'")
return [
*super().js_init,
f"this.raphael = paper.text({self.x}, {self.y}, '{escaped_text}');",
]
[docs]
class Image(GraphicObject):
"""
An image object.
Parameters
----------
id_
A unique identifier for the object.
media_id
The ID for the media source, which will be looked up in the graphic's `:class:`psynet.timeline.MediaSpec`
object.
x
x coordinate.
y
y coordinate.
width
Width of the drawn image.
height
Height of the drawn image. If ``None``, the height is set automatically with reference to ``width``.
anchor_x
Determines the x-alignment of the image. A value of 0.5 (default) means that the image is center-aligned.
This alignment is achieved using the ``transform`` attribute of the image,
bear this in mind when overriding this attribute.
anchor_y
Determines the y-alignment of the image. A value of 0.5 (default) means that the image is center-aligned.
This alignment is achieved using the ``transform`` attribute of the image,
bear this in mind when overriding this attribute.
**kwargs
Additional parameters passed to :class:`~psynet.graphic.GraphicObject`.
"""
def __init__(
self,
id_: str,
media_id: str,
x: int,
y: int,
width: int,
height: Optional[int] = None,
anchor_x: float = 0.5, # 0 means align to the left side; 1 means align to the right side
anchor_y: float = 0.5, # 0 means align to the top side; 1 means align to the bottom side
**kwargs,
):
super().__init__(id_, **kwargs)
self.media_id = media_id
self.x = round(x)
self.y = round(y)
self.width = round(width)
self.anchor_x = anchor_x
self.anchor_y = anchor_y
if height is None:
self.height = None
else:
self.height = round(height)
@property
def js_init(self):
if self.height is None:
height_js = f"Math.round({self.width} / psynet.image['{self.media_id}'].aspectRatio)"
else:
height_js = self.height
return [
*super().js_init,
f"this.raphael = paper.image(psynet.image['{self.media_id}'].url, {self.x}, {self.y}, {self.width}, {height_js});",
]
@property
def js_edit(self):
return [
*super().js_edit,
f"""
{{
let width = this.raphael.attr('width');
let height = this.raphael.attr('height');
this.x_offset = - Math.round(width * {self.anchor_x});
this.y_offset = - Math.round(height * {self.anchor_y});
this.raphael.transform('t' + this.x_offset + ',' + this.y_offset);
}}
""",
]
[docs]
class Path(GraphicObject):
"""
A path object. Paths provide the most flexible way to draw arbitrary vector graphics.
Parameters
----------
id_
A unique identifier for the object.
path_string
The object's path string. This should be in SVG format,
see https://dmitrybaranovskiy.github.io/raphael/reference.html#Paper.path
and https://www.w3.org/TR/SVG/paths.html#PathData for information.
**kwargs
Additional parameters passed to :class:`~psynet.graphic.GraphicObject`.
"""
def __init__(self, id_: str, path_string: str, **kwargs):
super().__init__(id_, **kwargs)
self.path_string = path_string
@property
def js_init(self) -> str:
return [*super().js_init, f"this.raphael = paper.path('{self.path_string}');"]
[docs]
class Circle(GraphicObject):
"""
A circle object.
Parameters
----------
id_
A unique identifier for the object.
x
x coordinate.
y
y coordinate.
radius
The circle's radius.
**kwargs
Additional parameters passed to :class:`~psynet.graphic.GraphicObject`.
"""
def __init__(self, id_: str, x: int, y: int, radius: int, **kwargs):
super().__init__(id_, **kwargs)
self.x = round(x)
self.y = round(y)
self.radius = round(radius)
@property
def js_init(self) -> str:
return [
*super().js_init,
f"this.raphael = paper.circle({self.x}, {self.y}, {self.radius});",
]
[docs]
class Ellipse(GraphicObject):
"""
An ellipse object.
Note that for a rotated ellipse you should use the ``transform`` attribute.
Parameters
----------
id_
A unique identifier for the object.
x
x coordinate.
y
y coordinate.
radius_x
The ellipse's x radius.
radius_y
The ellipses's y radius.
**kwargs
Additional parameters passed to :class:`~psynet.graphic.GraphicObject`.
"""
def __init__(
self, id_: str, x: int, y: int, radius_x: int, radius_y: int, **kwargs
):
super().__init__(id_, **kwargs)
self.x = round(x)
self.y = round(y)
self.radius_x = round(radius_x)
self.radius_y = round(radius_y)
@property
def js_init(self):
return [
*super().js_init,
f"this.raphael = paper.ellipse({self.x}, {self.y}, {self.radius_x}, {self.radius_y});",
]
[docs]
class Rectangle(GraphicObject):
"""
A rectangle object.
Parameters
----------
id_
A unique identifier for the object.
x
x coordinate.
y
y coordinate.
width
Width.
height
Height.
corner_radius
Radius of the rounded corners, defaults to zero (no rounding).
**kwargs
Additional parameters passed to :class:`~psynet.graphic.GraphicObject`.
"""
def __init__(
self,
id_: str,
x: int,
y: int,
width: int,
height: int,
corner_radius: int = 0,
**kwargs,
):
super().__init__(id_, **kwargs)
self.x = round(x)
self.y = round(y)
self.width = round(width)
self.height = round(height)
self.corner_radius = round(corner_radius)
@property
def js_init(self):
return [
*super().js_init,
f"this.raphael = paper.rect({self.x}, {self.y}, {self.width}, {self.height}, {self.corner_radius});",
]
[docs]
class GraphicPrompt(GraphicMixin, Prompt):
"""
A graphic prompt for use in :class:`psynet.modular_page.ModularPage`.
Parameters
----------
prevent_control_response
If ``True``, the response interface in the :class:`psynet.modular_page.Control` object is not activated
until explicitly instructed by one of the :class:`psynet.graphic.Frame` objects.
prevent_control_submit
If ``True``, participants are not allowed to submit responses
until explicitly instructed by one of the :class:`psynet.graphic.Frame` objects.
**kwargs
Parameters passed to :class:`~psynet.graphic.GraphicMixin` and :class:`~psynet.modular_page.Prompt`.
"""
macro = "graphic"
def __init__(
self,
*,
prevent_control_response: bool = False,
prevent_control_submit: bool = False,
**kwargs,
):
super().__init__(id_="prompt", **kwargs)
self.prevent_control_response = prevent_control_response
self.prevent_control_submit = prevent_control_submit
self.validate()
@property
def metadata(self):
return super().metadata
def validate(self):
response_activated, submit_activated = (False, False)
for frame in self.frames:
if frame.activate_control_response:
response_activated = True
if frame.activate_control_submit:
submit_activated = True
if self.prevent_control_response and not response_activated:
raise ValueError(
"if prevent_control_response == True, then at least one frame must have activate_control_response == True"
)
if self.prevent_control_submit and not submit_activated:
raise ValueError(
"if prevent_control_submit == True, then at least one frame must have activate_control_submit == True"
)
return True
def update_events(self, events):
if self.prevent_control_response:
events["responseEnable"].add_trigger("graphicPromptEnableResponse")
if self.prevent_control_submit:
events["submitEnable"].add_trigger("graphicPromptEnableSubmit")
[docs]
class GraphicControl(GraphicMixin, Control):
"""
A graphic control for use in :class:`psynet.modular_page.ModularPage`.
Parameters
----------
auto_advance_after : float
If not ``None``, a time in seconds after which the page will automatically advance to the next page.
**kwargs
Parameters passed to :class:`~psynet.graphic.GraphicMixin` and :class:`~psynet.modular_page.Control`.
"""
macro = "graphic"
def __init__(self, auto_advance_after: Optional[float] = None, **kwargs):
super().__init__(id_="control", show_next_button=False, **kwargs)
self.auto_advance_after = auto_advance_after
@property
def metadata(self):
return super().metadata
def visualize_response(self, answer, response, trial):
raise NotImplementedError
[docs]
def get_bot_response(self, experiment, bot, page, prompt):
if self.auto_advance_after is not None:
return None
else:
clickable_objects = [
obj
for frame in self.frames
for obj in frame.objects
if obj.click_to_answer
]
obj = random.choice(clickable_objects)
try:
coord = [obj.x, obj.y]
except AttributeError:
coord = [random.randint(0, span) for span in self.dimensions]
return {"clicked_object": obj.id, "click_coordinates": coord}
# html = (
# super().visualize_response(answer, response, trial) +
# "\n" +
# tags.div(
# tags.p(f"Answer = {answer}"),
# tags.video(
# tags.source(src=self.url),
# id="visualize-video-slider",
# controls=True,
# style="max-width: 400px;"
# )
# ).render()
# )
# return html
[docs]
class GraphicPage(ModularPage):
"""
A page that contains a single graphic.
Parameters
----------
label
A label for the page.
time_estimate
A time estimate for the page (seconds).
**kwargs
Parameters passed to :class:`~psynet.graphic.GraphicControl`.
"""
def __init__(self, label, *, time_estimate, **kwargs):
super().__init__(
label,
Prompt(),
GraphicControl(**kwargs),
time_estimate=time_estimate,
)
class SVGLogo:
def __init__(self, svg_path, id, width, height, alt_text="logo", url=None):
self.svg_path = svg_path
self.width = width
self.height = height
self.alt_text = alt_text
self.id = id
self.url = url
def __str__(self):
return self.html
@property
def html(self):
with open(self.svg_path, "r") as f:
svg = f.read()
svg = svg.replace("\n", "")
alt_id = f"{self.id}_alt"
svg = svg.replace(
"<svg",
f'<svg width="{self.width}" height="{self.height}" labelledby="{alt_id}"',
)
end_svg = re.search("<svg.+?>", svg).end()
svg = (
svg[:end_svg]
+ f'<desc id="{alt_id}">{self.alt_text}</desc>'
+ svg[end_svg:]
)
if self.url is not None:
svg = svg.replace("<svg", f'<svg onclick="window.open("{self.url}")"')
return svg
class PsyNetLogo(SVGLogo):
def __init__(
self,
svg_path=resources.files("psynet") / "resources/images/psynet.svg",
id="psynet-logo",
width="100px",
height="83px",
alt_text="Psynet",
url="https://www.psynet.dev/",
**kwargs,
):
super().__init__(
svg_path, id, width, height, alt_text=alt_text, url=url, **kwargs
)
class CAPLogo(SVGLogo):
def __init__(
self,
svg_path=resources.files("psynet") / "resources/images/cap.svg",
id="cap-logo",
width="125px",
height="83px",
alt_text="Computational Audition Group",
url="https://www.aesthetics.mpg.de/en/research/research-group-computational-auditory-perception.html",
**kwargs,
):
super().__init__(
svg_path, id, width, height, alt_text=alt_text, url=url, **kwargs
)
class MPIAELogo(SVGLogo):
def __init__(
self,
svg_path=resources.files("psynet") / "resources/images/mpiae.svg",
id="mpiae-logo",
width="200px",
height="83px",
alt_text="Max Planck Institute for Empirical Aesthetics",
url="https://www.aesthetics.mpg.de/en.html",
**kwargs,
):
super().__init__(
svg_path, id, width, height, alt_text=alt_text, url=url, **kwargs
)
class CambridgeLogo(SVGLogo):
def __init__(
self,
svg_path=resources.files("psynet") / "resources/images/cambridge.svg",
id="cambridge-logo",
width="180px",
height="83px",
alt_text="University of Cambridge",
**kwargs,
):
super().__init__(svg_path, id, width, height, alt_text=alt_text, **kwargs)
class PrincetonLogo(SVGLogo):
def __init__(
self,
svg_path=resources.files("psynet") / "resources/images/princeton.svg",
id="princeton-logo",
width="160px",
height="83px",
alt_text="Princeton University",
**kwargs,
):
super().__init__(svg_path, id, width, height, alt_text=alt_text, **kwargs)